Merge remote-tracking branch 'origin/main' into pen-path-operations
# Conflicts: # ATTRIBUTION.md # docs/ui-parity.md
@@ -37,7 +37,8 @@ concurrency:
|
||||
jobs:
|
||||
# Which OSes the test job runs on. main, manual runs and PRs labelled `full-ci` get Linux and
|
||||
# Windows. Other PRs get Linux, plus Windows when they touch code whose behaviour differs by
|
||||
# platform: the app shell, UI, text and fonts, GPU, pen input, build scripts or the lockfile.
|
||||
# platform: the app shell, UI, text and fonts, GPU, pen input, native saves, build scripts or
|
||||
# the lockfile. Format changes must exercise the Windows-only publication barriers (#8034).
|
||||
# macOS is tested only by release.yml.
|
||||
platforms:
|
||||
timeout-minutes: 10
|
||||
@@ -78,7 +79,7 @@ jobs:
|
||||
else
|
||||
echo "web=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
if git diff --name-only "$BASE" HEAD | grep -Eq '^(apps/|crates/(ui-egui|text|gpu|tablet|heif)/|Cargo\.lock$|(.*/)?build\.rs$|\.github/workflows/ci\.yml$)'; then
|
||||
if git diff --name-only "$BASE" HEAD | grep -Eq '^(apps/|crates/(ui-egui|text|gpu|tablet|heif|format)/|Cargo\.lock$|(.*/)?build\.rs$|\.github/workflows/ci\.yml$)'; then
|
||||
echo "legs=$all" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "legs=[$linux]" >> "$GITHUB_OUTPUT"
|
||||
|
||||
@@ -32,5 +32,7 @@ jobs:
|
||||
with:
|
||||
craft-fonts-ref: abb83316d96aa59c1cf64784289e378fe9fa5695
|
||||
cargo-build-extra-args: "--features heif"
|
||||
custom-test-command: "CARGO_PROFILE_DEV_DEBUG=0 cargo test --workspace --locked --features heif --exclude photocraft-ui-egui && CARGO_PROFILE_DEV_DEBUG=0 cargo test -p photocraft-ui-egui --locked --lib && CARGO_PROFILE_DEV_DEBUG=0 cargo test -p photocraft-ui-egui --locked --doc && CARGO_PROFILE_DEV_DEBUG=0 cargo test -p photocraft-ui-egui --locked --test '*' -- --test-threads=1"
|
||||
# The VM's software Vulkan driver crashes (SIGSEGV) when several tests set up a device at
|
||||
# once, so photocraft-gpu's tests run one at a time, like ui-egui's integration tests.
|
||||
custom-test-command: "CARGO_PROFILE_DEV_DEBUG=0 cargo test --workspace --locked --features heif --exclude photocraft-ui-egui --exclude photocraft-gpu && CARGO_PROFILE_DEV_DEBUG=0 cargo test -p photocraft-gpu --locked -- --test-threads=1 && CARGO_PROFILE_DEV_DEBUG=0 cargo test -p photocraft-ui-egui --locked --lib && CARGO_PROFILE_DEV_DEBUG=0 cargo test -p photocraft-ui-egui --locked --doc && CARGO_PROFILE_DEV_DEBUG=0 cargo test -p photocraft-ui-egui --locked --test '*' -- --test-threads=1"
|
||||
run-package-check: true
|
||||
|
||||
@@ -25,9 +25,10 @@ next to them.
|
||||
| `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) |
|
||||
| `crates/ui-egui/src/i18n/ru.tsv` | Russian 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`, `slice-select.svg`, `sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `ellipse-dashed.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`, `quick-selection.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`, `path-op-*.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`, `slice-select.svg`, `sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `ellipse-dashed.svg`, `gradient.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `text-warp.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `quick-selection.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`, `path-op-*.svg` and `marquee-{row,column}.svg`, `align-edge-*.svg` and `distribute-*.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/slice-select.svg` | Slice Select tool glyph | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
|
||||
@@ -37,9 +38,11 @@ next to them.
|
||||
| `assets/icons/perspective-crop.svg` | Perspective Crop tool glyph: a crop frame in perspective with its grid and crop-mark ticks | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
|
||||
| `assets/icons/type-mask.svg`, `assets/icons/type-mask-vertical.svg` | Horizontal and Vertical Type Mask tool glyphs: a T drawn as marching ants (the vertical one beside a downward arrow), on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
|
||||
| `assets/icons/path-op-{new-layer,combine,subtract,intersect,exclude,merge}.svg` | Path Operations glyphs (Pen options bar): a single square for New Layer, and two overlapping squares showing the area each operation keeps (union, back minus front, overlap, everything but the overlap, the merged outline), drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
|
||||
| `assets/icons/text-warp.svg` | Create Warped Text glyph (Type tool options bar): a T above a bowed arc, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
|
||||
| `assets/icons/marquee-row.svg`, `assets/icons/marquee-column.svg` | Single Row and Single Column Marquee tool glyphs: a dashed row (column) between two edge bars, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
|
||||
| `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/align-edge-*.svg`, `assets/icons/distribute-*.svg` | Move tool options-bar glyphs: Align (six edges/centres) and Distribute (edges, centres, spacing), a guide line or edge lines with bars, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
|
||||
| `assets/icons/ellipse-dashed.svg` | Elliptical Marquee tool glyph: a dashed horizontal ellipse, 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 |
|
||||
|
||||
@@ -4716,8 +4716,8 @@ dependencies = [
|
||||
"photocraft-doc",
|
||||
"photocraft-engine",
|
||||
"photocraft-format",
|
||||
"photocraft-geom",
|
||||
"photocraft-io",
|
||||
"photocraft-raw",
|
||||
"rmcp",
|
||||
"rustix 1.1.5",
|
||||
"schemars",
|
||||
@@ -4736,6 +4736,7 @@ dependencies = [
|
||||
"photocraft-engine",
|
||||
"photocraft-format",
|
||||
"photocraft-io",
|
||||
"photocraft-raw",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"winresource",
|
||||
@@ -5018,9 +5019,6 @@ name = "photocraft-testkit"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"memory-stats",
|
||||
"photocraft-color",
|
||||
"photocraft-geom",
|
||||
"photocraft-raster",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
@@ -5076,6 +5074,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sys-locale",
|
||||
"tempfile",
|
||||
"toml",
|
||||
"unicode-bidi",
|
||||
"winsafe",
|
||||
|
||||
@@ -7,6 +7,10 @@
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/app-icon/hicolor/256x256/apps/ai.storyteller.photocraft.png" width="128" height="128" alt="PhotoCraft icon: an engraved nine-tailed fox on a blue tile">
|
||||
</p>
|
||||
|
||||
<h1 align="center">PhotoCraft</h1>
|
||||
|
||||
<p align="center">
|
||||
@@ -339,7 +343,7 @@ sha256-verified. Fetch them with `cargo xtask corpus --all` and run the tests wi
|
||||
|
||||
**New to PhotoCraft?** Download it from the [PhotoCraft page on getartcraft.com](https://getartcraft.com/apps/photocraft). That's the easiest way to install it.
|
||||
|
||||
**Want a specific build or format?** On GitHub, the [latest release](https://github.com/storytold/photocraft/releases/latest) has every build listed below, and [all releases](https://github.com/storytold/photocraft/releases) has earlier versions and their notes. `<ver>` in the file names is the version number, and `SHA256SUMS.txt` lists a checksum for every file.
|
||||
**Want a specific build or format?** On GitHub, the [latest release](https://github.com/storytold/photocraft/releases/latest) has every build listed below, and [all releases](https://github.com/storytold/photocraft/releases) have earlier versions and their notes. `<ver>` in the file names is the version number, and `SHA256SUMS.txt` lists a checksum for every file.
|
||||
|
||||
### Windows
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~30 points (ready for real work ~45% → ~75%, with reliable PSD) and ~1,100–1,800 h away
|
||||
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-13 · **Change:** minor (oracle floor 145 of 258 and 953 open issues, as in gaps.md and on GitHub; toolbox count 56 of 68, 12 missing) · **Target:** Adobe Photoshop 2026
|
||||
> **Last reviewed:** 2026-10-17 · **Last updated:** 2026-10-17 · **Change:** minor (Object Finder retains large enclosed foregrounds (#6548); Russian catalog covers every dynamic label; previously minor: oracle floor 145 of 258 and 953 open issues, as in gaps.md and on GitHub; toolbox count 56 of 68, 12 missing) · **Target:** Adobe Photoshop 2026
|
||||
|
||||
PhotoCraft's one-page summary of how close it is to Adobe Photoshop 2026 (27.11.0, measured from
|
||||
the installed app on 2026-10-10) at version 0.6.0. Detail: [target-app parity](docs/target-app-parity.md)
|
||||
@@ -104,7 +104,7 @@ because engine messages are still English. Detail: [localization-parity](docs/lo
|
||||
| Korean | `ko` | 100% | partial |
|
||||
| Vietnamese | `vi` | 0% | none |
|
||||
|
||||
Also shipped: Traditional Chinese, Russian, Ukrainian, Czech, Polish, Greek, Dutch, Italian (8).
|
||||
Also shipped: Traditional Chinese, Russian (every dynamic label, [notes](docs/localization-ru.md)), Ukrainian, Czech, Polish, Greek, Dutch, Italian (8).
|
||||
|
||||
## Upcoming
|
||||
|
||||
@@ -122,6 +122,7 @@ Detail and the next releases: [docs/roadmap.md](docs/roadmap.md).
|
||||
|
||||
| Date | Entry |
|
||||
|---|---|
|
||||
| 2026-10-17 | Object Finder classifies background by boundary coverage, retaining large enclosed foregrounds and rejecting surrounding backdrop (#6548); learned segmentation remains open (#2788) |
|
||||
| 2026-10-10 | Full re-measure against Photoshop 2026 27.11.0 (installed bundle inspected): breadth ~76%, ready ~45%, alpha. Progress docs reorganized to the craftrules standard (`parity.md` → `parity-checklist.md`, `parity-estimate.md` merged into `target-app-parity.md`). 0.6.0 released. |
|
||||
| 2026-10-09 | Remove tool, Rotate View, crop rotation; OpenRaster and Paint.NET; 39 Affinity documents in the corpus |
|
||||
| 2026-10-08 | 0.5.0. Affinity import; Magnetic Lasso, Pattern Stamp, Mixer Brush, Pencil, Patch, Vertical Type on the toolbar |
|
||||
@@ -135,7 +136,9 @@ Detail and the next releases: [docs/roadmap.md](docs/roadmap.md).
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-17 | minor | Object Finder retains large enclosed foregrounds (#6548) |
|
||||
| 2026-10-13 | minor | Focus item 1 quoted the oracle floor as 133 of 256; it is 145 of 258 (`crates/io/tests/corpus.rs`, gaps.md G1). Open issues 579 → 953 (2026-10-13) |
|
||||
| 2026-10-15 | minor | Russian catalog covers every dynamic UI label the other catalogs translate |
|
||||
| 2026-10-11 | minor | Toolbox count 56 of 68 (12 missing) with the Convert Point tool (#3204) |
|
||||
| 2026-10-10 | minor | Readiness by audience with hours per tier |
|
||||
| 2026-10-10 | minor | Mainstream-practitioner and essentials-user numbers added |
|
||||
|
||||
@@ -31,6 +31,10 @@ photocraft-codecs = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
|
||||
|
||||
[dev-dependencies]
|
||||
# Synthetic camera raws (#7789).
|
||||
photocraft-raw = { workspace = true, features = ["testgen"] }
|
||||
|
||||
[build-dependencies]
|
||||
# Icon + VERSIONINFO for photocraft-cli.exe; build.rs only uses it when targeting Windows.
|
||||
winresource = "0.1"
|
||||
|
||||
@@ -15,21 +15,27 @@ pub const USAGE: &str = "\
|
||||
photocraft-cli: headless PhotoCraft
|
||||
|
||||
USAGE:
|
||||
photocraft-cli convert <in> <out> [--format <ext>] [--quality <1-100>] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] [--dds-compression <none|bc1|bc2|bc3>] [--dds-mipmaps]
|
||||
photocraft-cli convert <in> <out> [--format <ext>] [--quality <1-100>] [--in-place] [--tiff-layers] [--tiff-compression <none|lzw|zip>] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] [--dds-compression <none|bc1|bc2|bc3>] [--dds-mipmaps] [--jpeg-format <baseline|optimized|progressive>]
|
||||
Convert between formats (.pcraft, .psd, .png, .jpg, .tif, .webp, .exr, …).
|
||||
TIFF output is flat unless --tiff-layers keeps the layers (Photoshop layer data).
|
||||
An <out> that is the <in> file is refused, as the result would replace the original;
|
||||
--in-place allows it.
|
||||
TIFF output is flat unless --tiff-layers keeps the layers (Photoshop layer data);
|
||||
--tiff-compression compresses it (none, the default; lzw; zip).
|
||||
--quality sets the JPEG or WebP quality; a WebP written with a quality is lossy, without one lossless.
|
||||
--tga-bits sets a TGA's bits per pixel: 32 writes the alpha channel (or else the transparency) as
|
||||
its alpha, 24 none. Without it: 32 with an alpha channel or transparency, else 24.
|
||||
--dpx-bits sets a DPX's bits per sample. Without it: 8 for an 8-bit document, else 16.
|
||||
--dds-compression sets a DDS texture's compression: none (uncompressed, the default), bc1
|
||||
(DXT1: alpha on or off), bc2 (DXT3) or bc3 (DXT5). --dds-mipmaps also writes the full mip chain.
|
||||
--jpeg-format picks a JPEG's scans, as JPEG Options does: baseline (the default), optimized
|
||||
(Huffman tables fitted to the image; RGB is written 4:2:0 and keeps the standard tables, so
|
||||
only Gray and CMYK shrink) or progressive.
|
||||
photocraft-cli info <file> [--compact]
|
||||
Print the document as JSON (size, mode, depth, layer tree).
|
||||
photocraft-cli run (<file> | --new <json>) --cmd <id> [--params <json>] [--cmd …] [--out <file>] [--format <ext>] [--quality <1-100>] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] [--dds-compression <none|bc1|bc2|bc3>] [--dds-mipmaps]
|
||||
photocraft-cli run (<file> | --new <json>) --cmd <id> [--params <json>] [--cmd …] [--out <file>] [--format <ext>] [--quality <1-100>] [--tiff-layers] [--tiff-compression <none|lzw|zip>] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] [--dds-compression <none|bc1|bc2|bc3>] [--dds-mipmaps] [--jpeg-format <baseline|optimized|progressive>]
|
||||
Open a file, run engine commands in order, save the result. Each --params
|
||||
applies to the preceding --cmd. Prints each command's JSON result.
|
||||
photocraft-cli batch --actions <actions.json|set.atn> [--action <name>] --in <dir> --out <dir> [--format <ext>] [--quality <1-100>] [--in-place] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] [--dds-compression <none|bc1|bc2|bc3>] [--dds-mipmaps]
|
||||
photocraft-cli batch --actions <actions.json|set.atn> [--action <name>] --in <dir> --out <dir> [--format <ext>] [--quality <1-100>] [--in-place] [--tiff-layers] [--tiff-compression <none|lzw|zip>] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] [--dds-compression <none|bc1|bc2|bc3>] [--dds-mipmaps] [--jpeg-format <baseline|optimized|progressive>]
|
||||
Apply an action list to every image in a directory. A Photoshop Actions file (.atn) is a
|
||||
set: its action --action names plays, as below. JSON steps are [id, params] pairs,
|
||||
{\"command\": id, \"params\": {…}} objects or bare ids, as a recorded action or droplet stores them
|
||||
@@ -73,20 +79,44 @@ struct Subcommand {
|
||||
const SUBCOMMANDS: &[Subcommand] = &[
|
||||
Subcommand {
|
||||
name: "convert",
|
||||
values: &["--format", "--quality", "--tga-bits", "--dpx-bits", "--dds-compression"],
|
||||
bare: &["--tiff-layers", "--dds-mipmaps"],
|
||||
values: &["--format", "--quality", "--tga-bits", "--dpx-bits", "--dds-compression", "--tiff-compression", "--jpeg-format"],
|
||||
bare: &["--in-place", "--tiff-layers", "--dds-mipmaps"],
|
||||
run: convert,
|
||||
},
|
||||
Subcommand { name: "info", values: &[], bare: &["--compact"], run: |a, out, _| info(a, out) },
|
||||
Subcommand {
|
||||
name: "run",
|
||||
values: &["--new", "--cmd", "--params", "--out", "--format", "--quality", "--tga-bits", "--dpx-bits", "--dds-compression"],
|
||||
values: &[
|
||||
"--new",
|
||||
"--cmd",
|
||||
"--params",
|
||||
"--out",
|
||||
"--format",
|
||||
"--quality",
|
||||
"--tga-bits",
|
||||
"--dpx-bits",
|
||||
"--dds-compression",
|
||||
"--tiff-compression",
|
||||
"--jpeg-format",
|
||||
],
|
||||
bare: &["--tiff-layers", "--dds-mipmaps"],
|
||||
run: run_cmds,
|
||||
},
|
||||
Subcommand {
|
||||
name: "batch",
|
||||
values: &["--actions", "--action", "--in", "--out", "--format", "--quality", "--tga-bits", "--dpx-bits", "--dds-compression"],
|
||||
values: &[
|
||||
"--actions",
|
||||
"--action",
|
||||
"--in",
|
||||
"--out",
|
||||
"--format",
|
||||
"--quality",
|
||||
"--tga-bits",
|
||||
"--dpx-bits",
|
||||
"--dds-compression",
|
||||
"--tiff-compression",
|
||||
"--jpeg-format",
|
||||
],
|
||||
bare: &["--in-place", "--tiff-layers", "--dds-mipmaps"],
|
||||
run: batch,
|
||||
},
|
||||
@@ -226,6 +256,18 @@ fn export_opts(a: &Args) -> Result<ExportOptions, String> {
|
||||
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"))?);
|
||||
}
|
||||
if let Some(c) = a.get("--tiff-compression") {
|
||||
o.encode.tiff_compression = match c {
|
||||
"none" => photocraft_codecs::TiffCompression::None,
|
||||
"lzw" => photocraft_codecs::TiffCompression::Lzw,
|
||||
"zip" => photocraft_codecs::TiffCompression::Deflate,
|
||||
_ => return Err(format!("bad --tiff-compression `{c}`: expected none, lzw or zip")),
|
||||
};
|
||||
}
|
||||
if let Some(f) = a.get("--jpeg-format") {
|
||||
o.encode.jpeg_format =
|
||||
photocraft_codecs::JpegFormat::parse(f).ok_or_else(|| format!("bad --jpeg-format `{f}`: expected baseline, optimized or progressive"))?;
|
||||
}
|
||||
if let Some(c) = a.get("--dds-compression") {
|
||||
o.encode.dds.compression =
|
||||
photocraft_io::DdsCompression::from_name(c).ok_or_else(|| format!("bad --dds-compression `{c}`: expected none, bc1, bc2 or bc3"))?;
|
||||
@@ -251,12 +293,28 @@ fn automation_workspace(args: &Args) -> Result<AuthorizedWorkspace, String> {
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
/// An output that is the input file itself, however it is spelt (relative, with `./`, through a
|
||||
/// hard or symbolic link), would replace the original, and a lossy re-encode can't be undone: it
|
||||
/// is refused unless `--in-place` asks for it, as `batch` refuses an `--out` that is its `--in`
|
||||
/// folder (#7437). The paths are compared when the command starts and the save opens `output`
|
||||
/// again later, so another process swapping in a link meanwhile goes unnoticed: this guards
|
||||
/// against mistakes, it is not a security boundary.
|
||||
fn refuse_overwriting_input(a: &Args, input: &str, output: &str) -> R {
|
||||
if !a.has("--in-place") && photocraft_engine::file_cmds::same_file(input, output) {
|
||||
return Err(format!("{output} is the input file, so the result would replace the original: choose another output, or pass --in-place to overwrite it"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn convert(a: &Args, _out: &mut dyn Write, err: &mut dyn Write) -> R {
|
||||
let [input, output] = a.positional.as_slice() else {
|
||||
return Err("convert needs <in> <out>".into());
|
||||
};
|
||||
let opts = export_opts(a)?;
|
||||
let o = files::open(Path::new(input)).map_err(|e| e.to_string())?;
|
||||
refuse_overwriting_input(a, input, output)?;
|
||||
let mut o = files::open(Path::new(input)).map_err(|e| e.to_string())?;
|
||||
// Preferences › Raw Defaults › Resolution at its default, as `run` and `info` open a raw.
|
||||
photocraft_engine::file_cmds::raw_defaults(&photocraft_engine::Session::new(), &o.warnings, &mut o.document);
|
||||
warn_all(err, &o.warnings);
|
||||
warn_all(err, &photocraft_engine::type_extra_cmds::missing_font_warnings(&o.document));
|
||||
let ws = files::save(&o.document, Path::new(output), a.get("--format"), &opts, None).map_err(|e| e.to_string())?;
|
||||
|
||||
@@ -563,6 +563,33 @@ fn tga_bits_choose_the_targa_depth() {
|
||||
}
|
||||
}
|
||||
|
||||
/// `--jpeg-format` picks the JPEG's scans: progressive writes a progressive frame (SOF2). One it
|
||||
/// doesn't name is refused and nothing is written.
|
||||
#[test]
|
||||
fn jpeg_format_chooses_the_scans() {
|
||||
let d = tmp("jpeg-format");
|
||||
let a = d.join("a.png");
|
||||
write_png(&a, 16, 8, 3);
|
||||
let marker = |b: &[u8], m: u8| b.windows(2).any(|w| w == [0xFF, m]);
|
||||
for (format, progressive) in [(None, false), (Some("baseline"), false), (Some("optimized"), false), (Some("progressive"), true)] {
|
||||
let out = d.join("a.jpg");
|
||||
let mut convert = bin();
|
||||
convert.arg("convert").arg(&a).arg(&out);
|
||||
if let Some(f) = format {
|
||||
convert.args(["--jpeg-format", f]);
|
||||
}
|
||||
ok(&mut convert);
|
||||
assert_eq!(marker(&std::fs::read(&out).unwrap(), 0xC2), progressive, "--jpeg-format {format:?}");
|
||||
}
|
||||
for bad in ["Progressive", "1", ""] {
|
||||
let out = d.join("bad.jpg");
|
||||
let o = bin().arg("convert").arg(&a).arg(&out).args(["--jpeg-format", bad]).output().unwrap();
|
||||
assert_eq!(o.status.code(), Some(1), "--jpeg-format {bad}");
|
||||
assert!(String::from_utf8_lossy(&o.stderr).contains(&format!("bad --jpeg-format `{bad}`")), "--jpeg-format {bad}");
|
||||
assert!(!out.exists(), "--jpeg-format {bad} wrote a file");
|
||||
}
|
||||
}
|
||||
|
||||
/// #492: an `--out` folder that is the `--in` folder, however it is spelt, is refused before
|
||||
/// anything is written, unless `--in-place` asks for it.
|
||||
#[test]
|
||||
@@ -609,6 +636,42 @@ fn batch_refuses_to_write_over_its_inputs() {
|
||||
assert!(after.iter().zip(&before).all(|(a, b)| a.0 == b.0 && a.1 != b.1), "both inputs replaced");
|
||||
}
|
||||
|
||||
/// #7437: `convert` to its own input file, however the output is spelt, is refused before
|
||||
/// anything is read or written, unless `--in-place` asks for it (as `batch` does, #492).
|
||||
#[test]
|
||||
fn convert_refuses_to_write_over_its_input() {
|
||||
let d = tmp("convert-in-place");
|
||||
write_png(&d.join("in.png"), 8, 4, 5);
|
||||
let before = std::fs::read(d.join("in.png")).unwrap();
|
||||
let convert = |out: &Path, extra: &[&str]| bin().current_dir(&d).args(["convert", "in.png"]).arg(out).args(extra).output().unwrap();
|
||||
let mut outs = vec![PathBuf::from("in.png"), PathBuf::from("./in.png"), d.join("in.png"), d.join(".").join("in.png")];
|
||||
std::fs::hard_link(d.join("in.png"), d.join("hard.png")).unwrap();
|
||||
outs.push(PathBuf::from("hard.png"));
|
||||
#[cfg(unix)]
|
||||
{
|
||||
std::os::unix::fs::symlink(d.join("in.png"), d.join("soft.png")).unwrap();
|
||||
outs.push(PathBuf::from("soft.png"));
|
||||
}
|
||||
for out in &outs {
|
||||
for extra in [&[][..], &["--format", "png"][..], &["--quality", "80"][..]] {
|
||||
let o = convert(out, extra);
|
||||
let err = String::from_utf8_lossy(&o.stderr);
|
||||
assert_eq!(o.status.code(), Some(1), "{} {extra:?}: {err}", out.display());
|
||||
assert!(err.contains("is the input file") && err.contains("--in-place"), "{err}");
|
||||
assert_eq!(std::fs::read(d.join("in.png")).unwrap(), before, "{} {extra:?} changed the original", out.display());
|
||||
}
|
||||
}
|
||||
// Another file converts as before, leaving the original alone.
|
||||
ok(bin().current_dir(&d).args(["convert", "in.png", "out.jpg"]));
|
||||
assert!(d.join("out.jpg").is_file());
|
||||
assert_eq!(std::fs::read(d.join("in.png")).unwrap(), before);
|
||||
// `--in-place` opts in: the result replaces the original, which still opens.
|
||||
ok(bin().current_dir(&d).args(["convert", "in.png", "in.png", "--in-place"]));
|
||||
let (out, _) = ok(bin().current_dir(&d).args(["info", "in.png", "--compact"]));
|
||||
let info: Value = serde_json::from_str(&out).unwrap();
|
||||
assert_eq!((info["width"].as_u64(), info["height"].as_u64()), (Some(8), Some(4)), "{info}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commands_listing() {
|
||||
let (out, _) = ok(bin().arg("commands"));
|
||||
@@ -796,3 +859,136 @@ fn batch_plays_a_photoshop_actions_file() {
|
||||
assert!(err.contains("Generative Fill"), "{err}");
|
||||
assert!(!o.status.success());
|
||||
}
|
||||
|
||||
/// `photocraft_cli::run` in-process: `(exit code, stdout, stderr)`.
|
||||
fn invoke(args: &[&str]) -> (i32, String, String) {
|
||||
let (mut out, mut err) = (Vec::new(), Vec::new());
|
||||
let args: Vec<String> = args.iter().map(|s| s.to_string()).collect();
|
||||
let code = photocraft_cli::run(&args, &mut out, &mut err);
|
||||
(code, String::from_utf8(out).unwrap(), String::from_utf8(err).unwrap())
|
||||
}
|
||||
|
||||
const SUBCOMMANDS: [&str; 8] = ["convert", "info", "run", "batch", "droplet", "commands", "mcp", "serve"];
|
||||
|
||||
/// Pins top-level dispatch: help goes to stdout (exit 0), no arguments is a usage error on stderr.
|
||||
#[test]
|
||||
fn top_level_help_version_and_no_arguments() {
|
||||
let (code, out, err) = invoke(&[]);
|
||||
assert_eq!((code, out.is_empty(), err.is_empty()), (2, true, false));
|
||||
for help in ["--help", "-h", "help"] {
|
||||
let (code, out, err) = invoke(&[help]);
|
||||
assert_eq!((code, out.is_empty(), err.is_empty()), (0, false, true), "{help}");
|
||||
}
|
||||
for version in ["--version", "version"] {
|
||||
let (code, out, err) = invoke(&[version]);
|
||||
assert_eq!((code, err.as_str()), (0, ""), "{version}");
|
||||
assert!(out.starts_with("photocraft-cli "), "{version}: {out}");
|
||||
}
|
||||
let (code, out, err) = invoke(&["frobnicate"]);
|
||||
assert_eq!((code, out.is_empty()), (2, true));
|
||||
assert!(err.contains("frobnicate"), "{err}");
|
||||
}
|
||||
|
||||
/// `<sub> --help` / `-h` prints help on stdout and returns without running the subcommand
|
||||
/// (`mcp` and `serve` would otherwise block on stdin).
|
||||
#[test]
|
||||
fn every_subcommand_help_returns_on_stdout() {
|
||||
for sub in SUBCOMMANDS {
|
||||
for help in ["--help", "-h"] {
|
||||
let (code, out, err) = invoke(&[sub, help]);
|
||||
assert_eq!((code, out.is_empty(), err.is_empty()), (0, false, true), "{sub} {help}: {err}");
|
||||
// `commands` would also exit 0 with output if it ran; its list names `file.new`, help doesn't.
|
||||
assert!(!out.contains("file.new"), "{sub} {help} ran instead of printing help: {out}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Every subcommand refuses an unknown flag (bare or `=value`) with exit 2, naming it on stderr.
|
||||
#[test]
|
||||
fn every_subcommand_rejects_an_unknown_flag() {
|
||||
for sub in SUBCOMMANDS {
|
||||
for flag in [&["--fromat"][..], &["--fromat=jpg"][..], &["--fromat", "jpg"][..]] {
|
||||
let args: Vec<&str> = [sub].into_iter().chain(flag.iter().copied()).collect();
|
||||
let (code, out, err) = invoke(&args);
|
||||
assert_eq!((code, out.is_empty()), (2, true), "{args:?}: {err}");
|
||||
assert!(err.contains("--fromat"), "{args:?}: {err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `--flag=value`, a bare flag given a value, a value flag without one, and repeated flags.
|
||||
#[test]
|
||||
fn flag_value_forms_and_repeats() {
|
||||
let ids = |out: &str| out.lines().filter_map(|l| l.split_whitespace().next()).map(str::to_owned).collect::<Vec<_>>();
|
||||
let (code, eq, err) = invoke(&["commands", "--filter=file.new"]);
|
||||
assert_eq!(code, 0, "{err}");
|
||||
let (_, spaced, _) = invoke(&["commands", "--filter", "file.new"]);
|
||||
assert_eq!(eq, spaced);
|
||||
assert!(ids(&eq).contains(&"file.new".to_string()), "{eq}");
|
||||
// The last of a repeated value flag wins, whichever order they come in.
|
||||
let (code, out, err) = invoke(&["commands", "--filter", "zzz-no-match", "--filter", "file.new"]);
|
||||
assert_eq!(code, 0, "{err}");
|
||||
assert_eq!(out, eq);
|
||||
let (code, out, err) = invoke(&["commands", "--filter", "file.new", "--filter", "zzz-no-match"]);
|
||||
assert_eq!(code, 0, "{err}");
|
||||
assert!(out.is_empty(), "{out}");
|
||||
// A bare flag takes no value; a value flag needs one.
|
||||
let (code, out, err) = invoke(&["commands", "--json=1"]);
|
||||
assert_eq!((code, out.is_empty()), (2, true));
|
||||
assert!(err.contains("--json"), "{err}");
|
||||
let (code, out, err) = invoke(&["commands", "--filter"]);
|
||||
assert_eq!((code, out.is_empty()), (2, true));
|
||||
assert!(err.contains("--filter"), "{err}");
|
||||
}
|
||||
|
||||
/// Each `--params` applies to the `--cmd` before it; a `--cmd` without one gets `{}`; a
|
||||
/// `--params` ahead of every `--cmd` fails naming `--params`.
|
||||
#[test]
|
||||
fn run_pairs_params_with_the_preceding_cmd() {
|
||||
let (code, out, err) = invoke(&[
|
||||
"run",
|
||||
"--new",
|
||||
r#"{"width":8,"height":8}"#,
|
||||
"--cmd",
|
||||
"layer.new.layer",
|
||||
"--params",
|
||||
r#"{"name":"First"}"#,
|
||||
"--cmd",
|
||||
"layer.new.layer",
|
||||
"--cmd",
|
||||
"layer.new.layer",
|
||||
"--params",
|
||||
r#"{"name":"Third"}"#,
|
||||
"--cmd",
|
||||
"document.inspect",
|
||||
]);
|
||||
assert_eq!(code, 0, "{err}");
|
||||
let rows: Vec<Value> = out.lines().map(|l| serde_json::from_str(l).unwrap()).collect();
|
||||
assert_eq!(rows.len(), 4, "{out}");
|
||||
let names: Vec<&str> = rows[3]["result"]["layers"].as_array().unwrap().iter().filter_map(|l| l["name"].as_str()).collect();
|
||||
let count = |n: &str| names.iter().filter(|x| **x == n).count();
|
||||
assert_eq!((count("First"), count("Third")), (1, 1), "each --params applies to its own --cmd only: {names:?}");
|
||||
// Three new layers plus the background; the middle one (no --params) has a default name.
|
||||
assert_eq!(names.len(), 4, "{names:?}");
|
||||
// Layers list top first: Third, the unnamed middle one, First, Background. Pairing --params
|
||||
// with --cmd by position (params[i] -> cmd[i]) would name the middle one Third instead.
|
||||
assert_eq!((names[0], names[2]), ("Third", "First"), "{names:?}");
|
||||
let (code, _, err) = invoke(&["run", "--new", "{}", "--params", "{}", "--cmd", "layer.new.layer"]);
|
||||
assert_ne!(code, 0);
|
||||
assert!(err.contains("--params"), "{err}");
|
||||
}
|
||||
|
||||
/// #7789: `convert` opens a developed raw at Raw Defaults › Resolution (default 300 ppi), as
|
||||
/// `run`, `info` and the app do; it wrote 72.
|
||||
#[test]
|
||||
fn convert_opens_a_raw_at_the_raw_defaults_resolution() {
|
||||
use photocraft_raw::testgen::{DngSpec, mosaic, scene};
|
||||
let d = tmp("convert-raw");
|
||||
let (w, h) = (24, 16);
|
||||
let (raw, psd) = (d.join("IMG_0001.dng"), d.join("IMG_0001.psd"));
|
||||
std::fs::write(&raw, DngSpec::cfa(w, h, mosaic(&scene(w, h), w, [0, 1, 1, 2], 0, 65535)).build()).unwrap();
|
||||
ok(bin().args(["convert"]).arg(&raw).arg(&psd));
|
||||
let doc = photocraft_io::import("IMG_0001.psd", &std::fs::read(&psd).unwrap()).unwrap().document;
|
||||
assert_eq!(doc.resolution_dpi, 300.0);
|
||||
std::fs::remove_dir_all(d).unwrap();
|
||||
}
|
||||
|
||||
@@ -20,8 +20,9 @@ type Inbox = Arc<Mutex<Vec<(String, Vec<u8>)>>>;
|
||||
/// Photoshop brushes (.abr), gradients (.grd) and swatches (.aco, .act, .ase), which go to the preset libraries.
|
||||
const OPEN_EXTS: &[&str] = &[
|
||||
"pcraft", "pdn", "ora", "xcf", "xcfgz", "gz", "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", "cin", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef",
|
||||
"nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "act", "ase", "af", "afdesign", "afphoto", "afpub",
|
||||
"qoi", "exr", "hdr", "pbm", "pgm", "ppm", "pam", "pfm", "dds", "fits", "fit", "fts", "dpx", "cin", "dcm", "dicom", "dc3", "iff", "lbm", "ilbm", "heic",
|
||||
"heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "act", "ase", "af", "afdesign",
|
||||
"afphoto", "afpub",
|
||||
];
|
||||
const SVG_EXTS: &[&str] = &["svg", "svgz"];
|
||||
const CANVAS_ID: &str = "photocraft_canvas";
|
||||
@@ -390,8 +391,8 @@ impl eframe::App for WebShell {
|
||||
fn services(inbox: Inbox, place_inbox: Inbox, cursor_pos: photocraft_ui_egui::CursorPosFn) -> Services {
|
||||
Services {
|
||||
screen_pick: screen_color_service(),
|
||||
import: Some(Box::new(|name: &str, bytes: &[u8], max_svg_group_depth: usize| {
|
||||
photocraft_io::import_with_svg_group_depth(name, bytes, max_svg_group_depth).map(|r| (r.document, r.warnings)).map_err(|e| e.to_string())
|
||||
import: Some(Box::new(|name: &str, bytes: &[u8], opts: &photocraft_io::ImportOptions| {
|
||||
photocraft_io::import_with_options(name, bytes, opts).map(|r| (r.document, r.warnings)).map_err(|e| e.to_string())
|
||||
})),
|
||||
export: Some(Box::new(|doc: &Document, path: &str, settings: &photocraft_ui_egui::ExportSettings| {
|
||||
photocraft_io::export(doc, path, &settings.io_options()).map(|r| (r.bytes, r.warnings)).map_err(|e| e.to_string())
|
||||
|
||||
@@ -50,11 +50,12 @@ core-graphics2 = { version = "=0.6.1", default-features = false, features = ["di
|
||||
# safe API. Pinned exactly: review the diff before bumping.
|
||||
fmv-macos-events = "=0.1.0"
|
||||
# Safe AppKit calls only: turn off AppKit's title bar dragging (mac_window.rs) and find where the
|
||||
# pointer is when files are dropped (cursor.rs; winit 0.30 drops carry no position). Already in
|
||||
# pointer is when files are dropped (cursor.rs; winit 0.30 drops carry no position), and play the
|
||||
# system alert sound for Beep When Done (services.rs, NSBeep). Already in
|
||||
# Cargo.lock (winit, photocraft-tablet); pinned exactly to the same versions.
|
||||
objc2 = "=0.6.4"
|
||||
objc2-foundation = { version = "=0.3.2", default-features = false, features = ["std", "NSArray", "NSEnumerator", "NSGeometry"] }
|
||||
objc2-app-kit = { version = "=0.3.2", default-features = false, features = ["std", "NSApplication", "NSApplicationScripting", "NSEvent", "NSResponder", "NSView", "NSWindow"] }
|
||||
objc2-app-kit = { version = "=0.3.2", default-features = false, features = ["std", "NSApplication", "NSApplicationScripting", "NSEvent", "NSGraphics", "NSResponder", "NSView", "NSWindow"] }
|
||||
# The macOS menu bar (mac_menu.rs): a standalone native menu crate (from the tauri-apps org, but
|
||||
# not Tauri: no webview, no JS), AppKit menus behind a safe API (no GTK with default features off). Pinned exactly: review the diff before bumping.
|
||||
muda = { version = "=0.21.1", default-features = false }
|
||||
|
||||
@@ -25,7 +25,7 @@ pub fn start(port: u16, token: String, ctx: egui::Context) -> Receiver<ControlRe
|
||||
return rx;
|
||||
}
|
||||
};
|
||||
eprintln!("photocraft: control server listening on 127.0.0.1:{port}");
|
||||
eprintln!("photocraft: control server listening on {}", listening_addr(&listener, port));
|
||||
std::thread::spawn(move || {
|
||||
let limiter = ConnectionLimiter::new(MAX_CONNECTIONS);
|
||||
let token = Arc::new(token);
|
||||
@@ -47,6 +47,16 @@ pub fn start(port: u16, token: String, ctx: egui::Context) -> Receiver<ControlRe
|
||||
rx
|
||||
}
|
||||
|
||||
/// The address `listener` is bound to: with `--control 0` the OS picks the port, and a client
|
||||
/// needs that one, not the requested 0 (#6427). Falls back to the requested port if the OS
|
||||
/// can't report it.
|
||||
fn listening_addr(listener: &TcpListener, requested_port: u16) -> String {
|
||||
match listener.local_addr() {
|
||||
Ok(addr) => addr.to_string(),
|
||||
Err(_) => format!("127.0.0.1:{requested_port}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn serve(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::Context) {
|
||||
serve_with_auth_deadline(stream, token, tx, ctx, AUTH_DEADLINE);
|
||||
}
|
||||
@@ -131,6 +141,17 @@ mod tests {
|
||||
use super::*;
|
||||
use std::io::BufRead;
|
||||
|
||||
/// #6427: with port 0 the log names the port the OS bound, which a client can connect to.
|
||||
#[test]
|
||||
fn port_zero_logs_the_bound_port() {
|
||||
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
|
||||
let bound = listener.local_addr().unwrap();
|
||||
assert_ne!(bound.port(), 0);
|
||||
let logged = listening_addr(&listener, 0);
|
||||
assert_eq!(logged, format!("127.0.0.1:{}", bound.port()));
|
||||
TcpStream::connect(&logged).unwrap();
|
||||
}
|
||||
|
||||
/// #4965: a connection that keeps sending without authenticating is closed at the deadline
|
||||
/// (releasing its connection permit), and nothing it sent is dispatched.
|
||||
#[test]
|
||||
|
||||
@@ -524,6 +524,13 @@ fn main() -> eframe::Result {
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let single_instance = {
|
||||
let new_instance_env = std::env::var("PHOTOCRAFT_NEW_INSTANCE").ok();
|
||||
// A variable documented as the equivalent of a per-launch flag counts as that flag: a
|
||||
// launch that sets only `PHOTOCRAFT_PRESENT_MODE` (say) has per-process options too, so it
|
||||
// keeps its own process instead of handing its files over and being ignored.
|
||||
// Folded in here, inside the block that only non-macOS builds compile, so the binding
|
||||
// above keeps its `mut` off macOS, where nothing is folded in.
|
||||
let mut launch_options = launch_options;
|
||||
launch_options.extend_from_slice(&single_instance::env_launch_options());
|
||||
let context = single_instance::LaunchContext {
|
||||
per_user_settings: app_dirs::current().mode == app_dirs::Mode::Platform,
|
||||
new_instance_flag: new_instance,
|
||||
@@ -784,6 +791,9 @@ fn main() -> eframe::Result {
|
||||
if let Some(rs) = cc.wgpu_render_state.clone() {
|
||||
app.perf.gpu_info.set_adapter(&rs.adapter.get_info());
|
||||
app.perf.gpu_info.set_available(&rs.available_adapters);
|
||||
// Every egui texture uploads through this adapter, whichever canvas draws:
|
||||
// Iris Xe on DX12 shows stale memory in all-zero texels (texel_guard.rs).
|
||||
photocraft_ui_egui::texel_guard::install(&cc.egui_ctx, &rs.adapter.get_info());
|
||||
let software_window = rs.adapter.get_info().device_type == eframe::wgpu::DeviceType::Cpu;
|
||||
if software_window && mode != photocraft_engine::prefs::RenderingMode::Cpu {
|
||||
// Keep an earlier note, such as a requested adapter that couldn't be used.
|
||||
|
||||
@@ -156,6 +156,18 @@ fn file_uri_path(uri: &str) -> Option<PathBuf> {
|
||||
Some(PathBuf::from(std::ffi::OsString::from_vec(out)))
|
||||
}
|
||||
|
||||
/// Which file type's extensions belong on the name the dialog returned: the one it reported, or,
|
||||
/// when the dialog offered only one type, that one. It reports no pick then, and the name it was
|
||||
/// given is the stem (`services::dialog_file_name`), so a bare `Swatches` has to come back as
|
||||
/// `Swatches.aco`. `None` when the answer names no type this dialog offered.
|
||||
fn chosen_type(filters: &[Filter], picked: Option<usize>) -> Option<usize> {
|
||||
match picked {
|
||||
Some(i) if i < filters.len() => Some(i),
|
||||
None if filters.len() == 1 => Some(0),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Save As on Linux: the portal's dialog, with the name given the extension of the file type
|
||||
/// picked. When that changes the name to one that already exists, the dialog opens again under
|
||||
/// the new name, so the portal asks before replacing it (it asked only about the typed name).
|
||||
@@ -163,10 +175,10 @@ pub fn save_as(conn: &zbus::blocking::Connection, mut req: SaveRequest) -> Resul
|
||||
loop {
|
||||
let Some(saved) = save_file(conn, &req)? else { return Ok(None) };
|
||||
let picked = saved.filter.as_deref().and_then(|label| req.filters.iter().position(|(l, _)| l == label));
|
||||
// With one type offered there is nothing to pick, and a typed extension is the user's.
|
||||
let exts: &[String] = match picked {
|
||||
Some(i) if req.filters.len() > 1 => &req.filters[i].1,
|
||||
_ => &[],
|
||||
// A typed extension is the user's own: `with_chosen_type` keeps one it recognises.
|
||||
let exts: &[String] = match chosen_type(&req.filters, picked) {
|
||||
Some(i) => &req.filters[i].1,
|
||||
None => &[],
|
||||
};
|
||||
let path = crate::services::with_chosen_type(&saved.path, req.suggested.as_deref(), exts);
|
||||
if path == saved.path || !path.exists() {
|
||||
|
||||
@@ -235,3 +235,33 @@ fn a_single_file_type_keeps_the_typed_extension() {
|
||||
assert_eq!(save_as(&conn, req).unwrap(), Some(PathBuf::from("/tmp/photocraft-none/Swatches.ase")));
|
||||
daemon.kill().ok();
|
||||
}
|
||||
|
||||
/// The same single type, with the name left as the dialog shows it: the bare stem comes back, and
|
||||
/// that one type gives it its extension instead of a file named `Swatches` being written.
|
||||
#[test]
|
||||
fn a_single_file_type_gives_the_bare_name_its_extension() {
|
||||
let typed: &'static str = "file:///tmp/photocraft-none/Swatches";
|
||||
let Some((conn, _seen, mut daemon)) = mock_portal(vec![(0, Some((typed, "Color Swatches")))]) else {
|
||||
eprintln!("no dbus-daemon: skipped");
|
||||
return;
|
||||
};
|
||||
let req = SaveRequest { filters: vec![("Color Swatches".into(), vec!["aco".into()])], ..request("Swatches.aco") };
|
||||
assert_eq!(save_as(&conn, req).unwrap(), Some(PathBuf::from("/tmp/photocraft-none/Swatches.aco")));
|
||||
daemon.kill().ok();
|
||||
}
|
||||
|
||||
/// Which type's extensions the returned name gets. The portal cases above need a `dbus-daemon`,
|
||||
/// so the rule itself is checked here too, which needs none.
|
||||
#[test]
|
||||
fn the_type_that_gives_the_extension_is_the_one_picked() {
|
||||
let one: Vec<Filter> = vec![("Color Swatches".into(), vec!["aco".into()])];
|
||||
// One type offered: the dialog reports no pick, and it was given the stem.
|
||||
assert_eq!(chosen_type(&one, None), Some(0));
|
||||
assert_eq!(chosen_type(&one, Some(0)), Some(0));
|
||||
let many: Vec<Filter> = request("test").filters;
|
||||
assert_eq!(chosen_type(&many, Some(1)), Some(1));
|
||||
// Several types and no pick: nothing to add, as before.
|
||||
assert_eq!(chosen_type(&many, None), None);
|
||||
// An answer naming a type this dialog never offered is not a pick either.
|
||||
assert_eq!(chosen_type(&many, Some(99)), None);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
//! Native platform services: file dialogs (rfd), filesystem, codecs.
|
||||
|
||||
use photocraft_codecs::{ChannelLayout, EncodeOptions, Image, SampleType as CS};
|
||||
use photocraft_color::{ColorMode, SampleType};
|
||||
use photocraft_doc::{Document, Layer, Size};
|
||||
use photocraft_codecs::{ChannelLayout, EncodeOptions, Image};
|
||||
use photocraft_doc::Document;
|
||||
use photocraft_format::RecoveryStore;
|
||||
use photocraft_geom::Rect;
|
||||
use photocraft_ui_egui::{FileDialogAnswer, FileDialogReply, FileDialogRequest, Recoverable, Services};
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::future::Future;
|
||||
@@ -21,8 +19,9 @@ mod recovery_tests;
|
||||
/// (.kys), which go to the preset libraries and Edit › Keyboard Shortcuts.
|
||||
const OPEN_EXTS: &[&str] = &[
|
||||
"pcraft", "pdn", "ora", "xcf", "xcfgz", "gz", "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", "cin", "heic", "heif", "hif", "dng", "cr2",
|
||||
"cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "act", "ase", "kys", "af", "afdesign", "afphoto", "afpub",
|
||||
"qoi", "exr", "hdr", "pbm", "pgm", "ppm", "pam", "pfm", "pcx", "dds", "xpm", "fits", "fit", "fts", "dpx", "cin", "dcm", "dicom", "dc3", "iff", "lbm",
|
||||
"ilbm", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "act", "ase",
|
||||
"kys", "af", "afdesign", "afphoto", "afpub",
|
||||
];
|
||||
const SVG_EXTS: &[&str] = &["svg", "svgz"];
|
||||
|
||||
@@ -67,6 +66,8 @@ const OPEN_FILTERS: &[(&str, &[&str])] = &[
|
||||
("Radiance HDR", &["hdr"]),
|
||||
("FITS", &["fits", "fit", "fts"]),
|
||||
("DPX and Cineon", &["dpx", "cin"]),
|
||||
("Amiga IFF", &["iff", "lbm", "ilbm"]),
|
||||
("DICOM", &["dcm", "dicom", "dc3"]),
|
||||
("Camera Raw", &["dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf"]),
|
||||
("Brushes", &["abr"]),
|
||||
("Gradients", &["grd"]),
|
||||
@@ -102,6 +103,7 @@ const SAVE_FILTERS: &[(&str, &[&str])] = &[
|
||||
("DPX", &["dpx"]),
|
||||
// Game and 3D textures; DDS Options asks for the compression and mipmaps (#1327).
|
||||
("DDS", &["dds"]),
|
||||
("Cineon", &["cin"]),
|
||||
];
|
||||
|
||||
/// Non-document files the shell saves (Swatches panel exports): offered alone, so the dialog
|
||||
@@ -198,10 +200,10 @@ fn show_file_dialog(request: FileDialogRequest, parent: Option<&eframe::Frame>,
|
||||
};
|
||||
if multiple {
|
||||
let picked = dialog.pick_files();
|
||||
Box::pin(async move { picked.await.map(|files| FileDialogAnswer::Paths(files.iter().map(path_of).collect())) })
|
||||
Box::pin(async move { picked.await.map(|files| answer(files.iter().map(|f| f.path()), false, FileDialogAnswer::Paths)) })
|
||||
} else {
|
||||
let picked = dialog.pick_file();
|
||||
Box::pin(async move { picked.await.map(|file| FileDialogAnswer::Paths(vec![path_of(&file)])) })
|
||||
Box::pin(async move { picked.await.map(|file| answer([file.path()], false, FileDialogAnswer::Paths)) })
|
||||
}
|
||||
}
|
||||
FileDialogRequest::Folder { initial_dir } => {
|
||||
@@ -209,7 +211,7 @@ fn show_file_dialog(request: FileDialogRequest, parent: Option<&eframe::Frame>,
|
||||
dialog = dialog.set_directory(dir);
|
||||
}
|
||||
let picked = dialog.pick_folder();
|
||||
Box::pin(async move { picked.await.map(|folder| FileDialogAnswer::FolderTo(path_of(&folder))) })
|
||||
Box::pin(async move { picked.await.map(|folder| answer_one(folder.path(), true, FileDialogAnswer::FolderTo)) })
|
||||
}
|
||||
FileDialogRequest::Save { suggested } => {
|
||||
let filters = save_filters(&suggested);
|
||||
@@ -244,10 +246,10 @@ fn show_file_dialog(request: FileDialogRequest, parent: Option<&eframe::Frame>,
|
||||
.map_err(|e| e.to_string())
|
||||
.and_then(|conn| crate::portal_save::save_as(&conn, req));
|
||||
match portal {
|
||||
Ok(path) => path.map(|p| FileDialogAnswer::SaveTo(p.to_string_lossy().into_owned())),
|
||||
Ok(path) => path.map(|p| answer_one(&p, true, FileDialogAnswer::SaveTo)),
|
||||
Err(e) => {
|
||||
log::info!("portal save dialog unavailable ({e}), using rfd");
|
||||
dialog.save_file().await.map(|file| FileDialogAnswer::SaveTo(path_of(&file)))
|
||||
dialog.save_file().await.map(|file| answer_one(file.path(), true, FileDialogAnswer::SaveTo))
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -256,7 +258,7 @@ fn show_file_dialog(request: FileDialogRequest, parent: Option<&eframe::Frame>,
|
||||
{
|
||||
let _ = filters;
|
||||
let picked = dialog.save_file();
|
||||
Box::pin(async move { picked.await.map(|file| FileDialogAnswer::SaveTo(path_of(&file))) })
|
||||
Box::pin(async move { picked.await.map(|file| answer_one(file.path(), true, FileDialogAnswer::SaveTo)) })
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -270,8 +272,22 @@ fn show_file_dialog(request: FileDialogRequest, parent: Option<&eframe::Frame>,
|
||||
}
|
||||
}
|
||||
|
||||
fn path_of(file: &rfd::FileHandle) -> String {
|
||||
file.path().to_string_lossy().into_owned()
|
||||
/// The dialog's answer for the chosen `paths`, or the error for one whose name isn't valid
|
||||
/// Unicode, which a lossy copy would turn into another file's name (#6601). `saving`: the paths
|
||||
/// are to be written to.
|
||||
fn answer<'a>(paths: impl IntoIterator<Item = &'a Path>, saving: bool, make: impl FnOnce(Vec<String>) -> FileDialogAnswer) -> FileDialogAnswer {
|
||||
match paths.into_iter().map(|p| photocraft_ui_egui::file_open::path_text(p, saving)).collect() {
|
||||
Ok(paths) => make(paths),
|
||||
Err(e) => FileDialogAnswer::Unavailable(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// [`answer`] for a dialog that picks one path.
|
||||
fn answer_one(path: &Path, saving: bool, make: impl FnOnce(String) -> FileDialogAnswer) -> FileDialogAnswer {
|
||||
match photocraft_ui_egui::file_open::path_text(path, saving) {
|
||||
Ok(path) => make(path),
|
||||
Err(e) => FileDialogAnswer::Unavailable(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs `future` to completion on this thread, sleeping until it is woken.
|
||||
@@ -330,6 +346,44 @@ fn write_atomic_progress(path: &Path, bytes: &[u8], progress: &dyn Fn(f32)) -> R
|
||||
photocraft_format::atomic_write_progress(path, bytes, progress).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Whether `a` and `b` reach the same file: the same device and inode on Unix, the same final path
|
||||
/// on Windows (where it holds the name's letter case as stored). False when either can't be read.
|
||||
fn same_file(a: &Path, b: &Path) -> bool {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
match (std::fs::metadata(a), std::fs::metadata(b)) {
|
||||
(Ok(a), Ok(b)) => a.dev() == b.dev() && a.ino() == b.ino(),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
match (std::fs::canonicalize(a), std::fs::canonicalize(b)) {
|
||||
(Ok(a), Ok(b)) => a == b,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `a` and `b` name one entry in one folder: `same_file` finds they reach the same file
|
||||
/// in the same folder, and the folder doesn't list both names. Another spelling of a name on a
|
||||
/// file system that ignores letter case is the same entry; a hard link is another entry, which a
|
||||
/// save replaces while the first name keeps the old file. False when the folder can't be read.
|
||||
fn same_entry(a: &Path, b: &Path, same_file: impl Fn(&Path, &Path) -> bool) -> bool {
|
||||
let folder = |p: &Path| p.parent().filter(|d| !d.as_os_str().is_empty()).unwrap_or(Path::new(".")).to_path_buf();
|
||||
if !same_file(a, b) || !same_file(&folder(a), &folder(b)) {
|
||||
return false;
|
||||
}
|
||||
let (Some(x), Some(y)) = (a.file_name(), b.file_name()) else { return false };
|
||||
if x == y {
|
||||
return true;
|
||||
}
|
||||
let Ok(entries) = std::fs::read_dir(folder(a)) else { return false };
|
||||
let listed: Vec<_> = entries.filter_map(|e| e.ok().map(|e| e.file_name())).collect();
|
||||
!(listed.iter().any(|n| n == x) && listed.iter().any(|n| n == y))
|
||||
}
|
||||
|
||||
fn export_options(settings: &photocraft_ui_egui::ExportSettings) -> photocraft_io::ExportOptions {
|
||||
settings.io_options()
|
||||
}
|
||||
@@ -416,6 +470,17 @@ fn recovery_services(dir: Option<PathBuf>) -> Services {
|
||||
}
|
||||
}
|
||||
|
||||
/// The OS clipboard read and write run under the crash guard, like Open and Export (#7582): a
|
||||
/// panic in arboard, or in decoding a copied file, becomes the paste's (or copy's) error and the
|
||||
/// app keeps its documents.
|
||||
fn guarded_clipboard_get(mut read: impl FnMut() -> Result<Option<(u32, u32, Vec<u8>)>, String> + 'static) -> photocraft_ui_egui::ClipboardGetFn {
|
||||
Box::new(move || crate::crash_guard::guard("Paste", &mut read))
|
||||
}
|
||||
|
||||
fn guarded_clipboard_set(mut write: impl FnMut(u32, u32, &[u8]) -> Result<(), String> + 'static) -> photocraft_ui_egui::ClipboardSetFn {
|
||||
Box::new(move |w: u32, h: u32, px: &[u8]| crate::crash_guard::guard("Copy", || write(w, h, px)))
|
||||
}
|
||||
|
||||
/// The pixels to paste when the clipboard holds copied files rather than an image (Copy in
|
||||
/// Files, Finder or Explorer puts paths on the clipboard, #338): the first file that decodes,
|
||||
/// upright, as RGBA8. Files that aren't images are skipped after reading only their header.
|
||||
@@ -528,9 +593,9 @@ pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) ->
|
||||
}) as photocraft_ui_egui::AutomationCommandFn
|
||||
});
|
||||
Services {
|
||||
import: Some(Box::new(|name: &str, bytes: &[u8], max_svg_group_depth: usize| {
|
||||
import: Some(Box::new(|name: &str, bytes: &[u8], opts: &photocraft_io::ImportOptions| {
|
||||
crate::crash_guard::guard("Open", || {
|
||||
photocraft_io::import_with_svg_group_depth(name, bytes, max_svg_group_depth).map(|r| (r.document, r.warnings)).map_err(|e| e.to_string())
|
||||
photocraft_io::import_with_options(name, bytes, opts).map(|r| (r.document, r.warnings)).map_err(|e| e.to_string())
|
||||
})
|
||||
})),
|
||||
export: Some(Box::new(|doc: &Document, path: &str, settings: &photocraft_ui_egui::ExportSettings| {
|
||||
@@ -545,6 +610,7 @@ pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) ->
|
||||
let meta = std::fs::metadata(path).ok()?;
|
||||
Some(photocraft_ui_egui::FileStamp { len: meta.len(), modified: meta.modified().ok() })
|
||||
})),
|
||||
same_file: Some(Box::new(|a: &str, b: &str| same_entry(Path::new(a), Path::new(b), same_file))),
|
||||
automation_read,
|
||||
automation_write,
|
||||
automation_open_pcraft,
|
||||
@@ -560,7 +626,7 @@ pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) ->
|
||||
beep: Some(platform_beep),
|
||||
clipboard_set_image: Some({
|
||||
let clip = clip.clone();
|
||||
Box::new(move |w: u32, h: u32, px: &[u8]| {
|
||||
guarded_clipboard_set(move |w: u32, h: u32, px: &[u8]| {
|
||||
let mut slot = clip.try_borrow_mut().map_err(|_| "clipboard is busy".to_string())?;
|
||||
let cb = match slot.as_mut() {
|
||||
Some(c) => c,
|
||||
@@ -572,7 +638,7 @@ pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) ->
|
||||
clipboard_get_image: Some({
|
||||
let clip = clip.clone();
|
||||
let unreadable = clip_unreadable.clone();
|
||||
Box::new(move || {
|
||||
guarded_clipboard_get(move || {
|
||||
let mut slot = clip.try_borrow_mut().map_err(|_| "clipboard is busy".to_string())?;
|
||||
let cb = match slot.as_mut() {
|
||||
Some(c) => c,
|
||||
@@ -636,77 +702,19 @@ fn platform_beep() {
|
||||
{
|
||||
let _ = winsafe::MessageBeep(winsafe::co::MBP::OK);
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
// The user's alert sound, at the alert volume set in System Settings ▸ Sound. The terminal
|
||||
// bell below is silent for an app started from the Finder or the Dock.
|
||||
#[cfg(target_os = "macos")]
|
||||
objc2_app_kit::NSBeep();
|
||||
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
|
||||
{
|
||||
// No beep API among this app's other platforms' dependencies: write the terminal bell
|
||||
// (audible when launched from a terminal, which is the same cue macOS terminals use).
|
||||
// No beep API among the other platforms' dependencies: write the terminal bell (audible
|
||||
// when launched from a terminal).
|
||||
use std::io::Write as _;
|
||||
let _ = std::io::stdout().write_all(b"\x07").and_then(|()| std::io::stdout().flush());
|
||||
}
|
||||
}
|
||||
|
||||
/// Flat-image import via photocraft-codecs (kept for reference/tests; the app uses photocraft-io).
|
||||
#[allow(dead_code)]
|
||||
pub fn import_flat(name: &str, bytes: &[u8]) -> Result<Document, String> {
|
||||
let img = photocraft_codecs::decode(bytes).map_err(|e| e.to_string())?;
|
||||
let (w, h) = (img.width(), img.height());
|
||||
let depth = match img.sample_type() {
|
||||
CS::U8 => SampleType::U8,
|
||||
CS::U16 => SampleType::U16,
|
||||
_ => SampleType::F32,
|
||||
};
|
||||
let gray = matches!(img.layout(), ChannelLayout::Gray | ChannelLayout::GrayA);
|
||||
let cmyk = matches!(img.layout(), ChannelLayout::Cmyk | ChannelLayout::CmykA);
|
||||
let mode = if gray {
|
||||
ColorMode::Grayscale
|
||||
} else if cmyk {
|
||||
ColorMode::Cmyk
|
||||
} else {
|
||||
ColorMode::Rgb
|
||||
};
|
||||
let target = match mode {
|
||||
ColorMode::Grayscale => ChannelLayout::GrayA,
|
||||
ColorMode::Cmyk => ChannelLayout::CmykA,
|
||||
_ => ChannelLayout::Rgba,
|
||||
};
|
||||
let sample = match depth {
|
||||
SampleType::U8 => CS::U8,
|
||||
SampleType::U16 => CS::U16,
|
||||
SampleType::F32 => CS::F32,
|
||||
};
|
||||
let conv = img.convert(target, sample);
|
||||
let stem = std::path::Path::new(name).file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or(name.to_string());
|
||||
let mut doc = Document::new(stem, Size::new(w, h), mode, depth);
|
||||
doc.icc_profile = img.icc.clone().map(std::sync::Arc::new);
|
||||
if let Some((x, _)) = img.meta.dpi {
|
||||
doc.resolution_dpi = x;
|
||||
}
|
||||
let mut layer = Layer::raster("Background", doc.pixel_format());
|
||||
let data = conv.to_normalized();
|
||||
layer.surface_mut().ok_or("new raster layer has no pixels")?.write_region(Rect::from_xywh(0, 0, w, h), &data);
|
||||
doc.layers.push(layer);
|
||||
Ok(doc)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn export_flat(doc: &Document, path: &str) -> Result<Vec<u8>, String> {
|
||||
let format = photocraft_codecs::from_extension(path).ok_or_else(|| format!("unknown file type for {path}"))?;
|
||||
let buf = photocraft_compose::flatten(doc);
|
||||
let (w, h) = (buf.rect.width(), buf.rect.height());
|
||||
let data: Vec<f32> = buf.px.iter().flat_map(|p| *p).collect();
|
||||
let img = match doc.depth {
|
||||
SampleType::U8 => Image::from_u8(w, h, ChannelLayout::Rgba, buf.to_rgba8().pixels),
|
||||
SampleType::U16 => Image::from_u16(w, h, ChannelLayout::Rgba, &data.iter().map(|v| (v.clamp(0.0, 1.0) * 65535.0 + 0.5) as u16).collect::<Vec<_>>()),
|
||||
SampleType::F32 => Image::from_f32(w, h, ChannelLayout::Rgba, &data),
|
||||
}
|
||||
.map_err(|e| e.to_string())?;
|
||||
let img = match &doc.icc_profile {
|
||||
Some(icc) if doc.mode == ColorMode::Rgb => img.with_icc(Some((**icc).clone())),
|
||||
_ => img,
|
||||
};
|
||||
photocraft_codecs::encode(&img, format, &EncodeOptions::default()).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -715,6 +723,31 @@ mod tests {
|
||||
use photocraft_ui_egui::{PhotocraftApp, prefs_ui};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// #6601: a chosen path whose name isn't valid Unicode is reported, never handed on lossily
|
||||
/// (Open would fail with "No such file", Save would write a second, renamed file).
|
||||
#[test]
|
||||
fn a_chosen_path_that_is_not_valid_unicode_is_an_error_not_another_name() {
|
||||
#[cfg(unix)]
|
||||
let bad = {
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
std::env::temp_dir().join(std::ffi::OsStr::from_bytes(b"img\xFF.png"))
|
||||
};
|
||||
#[cfg(windows)]
|
||||
let bad = {
|
||||
use std::os::windows::ffi::OsStringExt;
|
||||
let mut wide: Vec<u16> = "img".encode_utf16().collect();
|
||||
wide.push(0xD800);
|
||||
wide.extend(".png".encode_utf16());
|
||||
std::env::temp_dir().join(std::ffi::OsString::from_wide(&wide))
|
||||
};
|
||||
let good = std::env::temp_dir().join("ok.png");
|
||||
let save = |path: &Path| answer_one(path, true, FileDialogAnswer::SaveTo);
|
||||
assert!(matches!(save(&bad), FileDialogAnswer::Unavailable(e) if e.contains("not valid Unicode") && e.contains("another name")));
|
||||
assert!(matches!(save(&good), FileDialogAnswer::SaveTo(p) if p == good.to_str().unwrap()));
|
||||
let open = answer([good.as_path(), bad.as_path()], false, FileDialogAnswer::Paths);
|
||||
assert!(matches!(open, FileDialogAnswer::Unavailable(e) if e.contains("open it again")));
|
||||
}
|
||||
|
||||
/// #6121: the file type picked in the save dialog gives the name its extension where the
|
||||
/// dialog left the one it opened with, and only there.
|
||||
#[test]
|
||||
@@ -1021,6 +1054,50 @@ mod tests {
|
||||
assert_eq!(dialog_file_name("", true), None);
|
||||
}
|
||||
|
||||
/// A name in another letter case reaches the same file where the file system ignores case
|
||||
/// (Windows, and macOS by default), and only there. Two files with the same contents are two.
|
||||
#[test]
|
||||
fn same_file_follows_the_file_system() {
|
||||
let dir = std::env::temp_dir().join(format!("photocraft-same-file-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let (cat, dog, upper) = (dir.join("CAT.psd"), dir.join("dog.psd"), dir.join("CAT.PSD"));
|
||||
std::fs::write(&cat, b"same").unwrap();
|
||||
std::fs::write(&dog, b"same").unwrap();
|
||||
assert!(same_file(&cat, &cat));
|
||||
assert!(!same_file(&cat, &dog));
|
||||
assert_eq!(same_file(&upper, &cat), upper.exists(), "the same file only where letter case is ignored");
|
||||
assert!(!same_file(&dir.join("missing.psd"), &cat));
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
/// A hard link is another entry in the folder, though it reaches the same file: a save to it
|
||||
/// replaces that entry and leaves the other name on the old file. Another spelling of a name,
|
||||
/// where letter case is ignored, is the same entry.
|
||||
#[test]
|
||||
fn a_hard_link_is_another_entry() {
|
||||
let dir = std::env::temp_dir().join(format!("photocraft-same-entry-{}", std::process::id()));
|
||||
std::fs::create_dir_all(dir.join("other")).unwrap();
|
||||
let (cat, bare, upper, other) = (dir.join("CAT.psd"), dir.join("CAT"), dir.join("CAT.PSD"), dir.join("other").join("CAT.psd"));
|
||||
std::fs::write(&cat, b"cat").unwrap();
|
||||
std::fs::hard_link(&cat, &bare).unwrap();
|
||||
std::fs::hard_link(&cat, &other).unwrap();
|
||||
// Where both names reach one file (one inode on Unix), the folder still lists both.
|
||||
let one_file = |_: &Path, _: &Path| true;
|
||||
assert!(!same_entry(&bare, &cat, one_file));
|
||||
assert!(!same_entry(&bare, &cat, same_file));
|
||||
assert!(!same_entry(&other, &cat, same_file), "another folder");
|
||||
assert!(same_entry(&cat, &cat, one_file));
|
||||
assert!(same_entry(&cat, &cat, same_file));
|
||||
if upper.exists() {
|
||||
assert!(same_entry(&upper, &cat, same_file), "one entry where letter case is ignored");
|
||||
} else {
|
||||
std::fs::hard_link(&cat, &upper).unwrap();
|
||||
assert!(!same_entry(&upper, &cat, same_file), "two entries where letter case tells names apart");
|
||||
}
|
||||
assert!(!same_entry(&dir.join("missing.psd"), &cat, same_file));
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_on_waits_for_a_wake_from_another_thread() {
|
||||
let (tx, rx) = std::sync::mpsc::channel::<std::task::Waker>();
|
||||
@@ -1298,4 +1375,29 @@ mod tests {
|
||||
assert_eq!(image_from_files(&[tga]), Some((1, 1, vec![255, 0, 0, 255])));
|
||||
assert!(image_from_files(&[bin]).is_none());
|
||||
}
|
||||
|
||||
/// #7582: a panic while reading the OS clipboard (in arboard, or decoding a copied file) is
|
||||
/// the paste's error in the status bar, not a crash: the document stays and the next paste
|
||||
/// works. The same for a panic while writing it.
|
||||
#[test]
|
||||
fn a_panicking_clipboard_read_is_reported_not_a_crash() {
|
||||
let panics = Rc::new(Cell::new(true));
|
||||
let p = panics.clone();
|
||||
let get = guarded_clipboard_get(move || if p.get() { panic!("clipboard decoder bug") } else { Ok(Some((2, 2, [0u8, 0, 255, 255].repeat(4)))) });
|
||||
let mut app = PhotocraftApp::new(Session::new(), Services { clipboard_get_image: Some(get), ..Default::default() });
|
||||
app.run("file.new", json!({"width": 8, "height": 8})).unwrap();
|
||||
let layers = app.session.active().unwrap().doc.layers.len();
|
||||
|
||||
let error = app.run("edit.paste", json!({})).unwrap_err();
|
||||
assert!(error.contains("Paste failed"), "{error}");
|
||||
assert!(app.ui.status_error && app.ui.status.contains("Paste failed"), "{}", app.ui.status);
|
||||
assert_eq!(app.session.active().unwrap().doc.layers.len(), layers, "the document is unchanged");
|
||||
|
||||
panics.set(false);
|
||||
app.run("edit.paste", json!({})).unwrap();
|
||||
assert_eq!(app.session.active().unwrap().doc.layers.len(), layers + 1, "the next paste works");
|
||||
|
||||
let mut set = guarded_clipboard_set(|_: u32, _: u32, _: &[u8]| panic!("clipboard writer bug"));
|
||||
assert!(set(1, 1, &[0; 4]).unwrap_err().contains("Copy failed"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use super::*;
|
||||
use photocraft_color::Color;
|
||||
use photocraft_color::{Color, ColorMode, SampleType};
|
||||
use photocraft_doc::Size;
|
||||
use photocraft_format::{Autosaver, RecoveryEntry, SaveOptions, list_recovery};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
|
||||
@@ -129,10 +129,71 @@ pub struct LaunchContext<'a> {
|
||||
pub control: bool,
|
||||
/// Paths that aren't valid Unicode: this launch reports them.
|
||||
pub unreadable_paths: bool,
|
||||
/// Options that only apply to the process they're given to (`--safe-gpu`, …).
|
||||
/// Options that only apply to the process they're given to (`--safe-gpu`, …), including the
|
||||
/// ones this launch's environment sets (see [`env_launch_options`]).
|
||||
pub launch_options: &'a [&'static str],
|
||||
}
|
||||
|
||||
/// Variables documented as the equivalent of a per-launch flag, each with the flag it stands for
|
||||
/// (`PHOTOCRAFT_PRESENT_MODE` is `--present-mode`) and the rule that says whether its value asks for
|
||||
/// that option at all. Setting one asks for the same thing as giving the flag, so it belongs to this
|
||||
/// process alone.
|
||||
const ENV_LAUNCH_OPTIONS: [(&str, &str, ValueRule); 6] = [
|
||||
("PHOTOCRAFT_PRESENT_MODE", "--present-mode", present_mode_is_on),
|
||||
("PHOTOCRAFT_FRAME_LATENCY", "--frame-latency", frame_latency_is_on),
|
||||
("PHOTOCRAFT_STROKE_TRACE", "--stroke-trace", stroke_trace_is_on),
|
||||
("PHOTOCRAFT_IN_WINDOW_MENUS", "--in-window-menus", flag_is_on),
|
||||
("PHOTOCRAFT_AUTOMATION_READ_ROOT", "--automation-read-root", path_is_set),
|
||||
("PHOTOCRAFT_AUTOMATION_WRITE_ROOT", "--automation-write-root", path_is_set),
|
||||
];
|
||||
|
||||
/// Whether a variable's value asks this launch for its own option. Each rule matches how that
|
||||
/// variable is read where it takes effect, so a value ignored there keeps no process here either.
|
||||
type ValueRule = fn(&str) -> bool;
|
||||
|
||||
/// A value `presentation::parse` accepts, handed over as written: it trims, treats empty as unset and
|
||||
/// warns and ignores anything it does not recognise, so an unrecognised `PHOTOCRAFT_PRESENT_MODE`
|
||||
/// changes nothing there and so needs no process of its own here.
|
||||
fn present_mode_is_on(value: &str) -> bool {
|
||||
crate::presentation::parse(Some(value), None).0.mode.is_some()
|
||||
}
|
||||
|
||||
/// [`present_mode_is_on`] for `PHOTOCRAFT_FRAME_LATENCY`, which takes `1` to `3`.
|
||||
fn frame_latency_is_on(value: &str) -> bool {
|
||||
crate::presentation::parse(None, Some(value)).0.latency.is_some()
|
||||
}
|
||||
|
||||
/// Anything except `""`, `0`, `off` and `false`, which is how `StrokeTrace::from_value` reads it: the
|
||||
/// rest is a stderr summary or a CSV path. It trims, so `" 0 "` is off there and off here.
|
||||
fn stroke_trace_is_on(value: &str) -> bool {
|
||||
!matches!(value.trim(), "" | "0" | "off" | "false")
|
||||
}
|
||||
|
||||
/// Any value except empty and exactly `0`, as `main.rs` reads `PHOTOCRAFT_IN_WINDOW_MENUS` - which is
|
||||
/// why `off` means on there, why this rule is not shared with the stroke trace above, and why it does
|
||||
/// not trim: a padded `" 0 "` enables the menus there, so it keeps a process here too.
|
||||
fn flag_is_on(value: &str) -> bool {
|
||||
!value.is_empty() && value != "0"
|
||||
}
|
||||
|
||||
/// A path is an option; an empty value is no path. Not trimmed: a path may end in a space. An empty
|
||||
/// root is read as no root unless `--control` is given, and that flag keeps its own process already.
|
||||
fn path_is_set(value: &str) -> bool {
|
||||
!value.is_empty()
|
||||
}
|
||||
|
||||
/// The per-process options this launch's environment sets, named as the flags they stand for. A
|
||||
/// launch that sets only variables would otherwise hand its files to the running instance, which
|
||||
/// never sees the override, and exit.
|
||||
pub fn env_launch_options() -> Vec<&'static str> {
|
||||
env_launch_options_from(|name| std::env::var_os(name))
|
||||
}
|
||||
|
||||
/// [`env_launch_options`] with the reader given, so the rule is testable without the environment.
|
||||
fn env_launch_options_from(get: impl Fn(&str) -> Option<std::ffi::OsString>) -> Vec<&'static str> {
|
||||
ENV_LAUNCH_OPTIONS.iter().filter(|(name, _, is_on)| get(name).is_some_and(|v| is_on(&v.to_string_lossy()))).map(|(_, flag, _)| *flag).collect()
|
||||
}
|
||||
|
||||
/// Why this launch runs as its own instance, or `None` to take part in single-instance.
|
||||
pub fn skip_reason(c: &LaunchContext) -> Option<String> {
|
||||
if !c.per_user_settings {
|
||||
@@ -915,4 +976,46 @@ mod tests {
|
||||
assert_eq!(sandbox_socket(Some(OsStr::new("")), Some(run)), None);
|
||||
assert_eq!(sandbox_socket(Some(id), Some(OsStr::new(""))), None);
|
||||
}
|
||||
|
||||
/// A variable documented as the equivalent of a per-launch flag counts as that flag: a launch
|
||||
/// that sets only variables has per-process options, so it keeps its own process rather than
|
||||
/// handing its files to the running instance, which never sees the override.
|
||||
#[test]
|
||||
fn an_environment_override_counts_as_a_process_option() {
|
||||
let with = |values: &[(&str, &str)]| env_launch_options_from(|n| values.iter().find(|(k, _)| *k == n).map(|(_, v)| (*v).into()));
|
||||
assert_eq!(with(&[("PHOTOCRAFT_PRESENT_MODE", "novsync")]), vec!["--present-mode"]);
|
||||
assert_eq!(
|
||||
with(&[("PHOTOCRAFT_STROKE_TRACE", "strokes.csv"), ("PHOTOCRAFT_AUTOMATION_WRITE_ROOT", "/tmp/out")]),
|
||||
vec!["--stroke-trace", "--automation-write-root"]
|
||||
);
|
||||
// Nothing set, or a value that means off, is an ordinary launch. Each variable is off the way
|
||||
// it is read where it takes effect, not by one rule shared across all six.
|
||||
assert!(with(&[]).is_empty());
|
||||
assert!(with(&[("PHOTOCRAFT_IN_WINDOW_MENUS", "0"), ("PHOTOCRAFT_FRAME_LATENCY", "")]).is_empty());
|
||||
// The trace is off at `off` and `false` as well, and a value the presentation parser rejects
|
||||
// is ignored there, so neither keeps a process of its own here.
|
||||
assert!(with(&[("PHOTOCRAFT_STROKE_TRACE", "off"), ("PHOTOCRAFT_PRESENT_MODE", "maybe"), ("PHOTOCRAFT_FRAME_LATENCY", "4")]).is_empty());
|
||||
assert!(with(&[("PHOTOCRAFT_AUTOMATION_READ_ROOT", "")]).is_empty());
|
||||
// Spaces are part of the value only where the reader keeps them: the trace and the two
|
||||
// presentation values are trimmed where they take effect, the menu flag and a path are not.
|
||||
assert_eq!(with(&[("PHOTOCRAFT_PRESENT_MODE", " novsync "), ("PHOTOCRAFT_STROKE_TRACE", " 1 ")]), vec!["--present-mode", "--stroke-trace"]);
|
||||
assert_eq!(
|
||||
with(&[("PHOTOCRAFT_IN_WINDOW_MENUS", " 0 "), ("PHOTOCRAFT_AUTOMATION_WRITE_ROOT", " /tmp/out ")]),
|
||||
vec!["--in-window-menus", "--automation-write-root"]
|
||||
);
|
||||
// A whitespace-only menu value is not empty, so it enables the menus there and here too.
|
||||
assert_eq!(with(&[("PHOTOCRAFT_IN_WINDOW_MENUS", " ")]), vec!["--in-window-menus"]);
|
||||
// `PHOTOCRAFT_IN_WINDOW_MENUS=off` means menus in the window there, so it means it here too.
|
||||
assert_eq!(with(&[("PHOTOCRAFT_IN_WINDOW_MENUS", "off")]), vec!["--in-window-menus"]);
|
||||
let base = LaunchContext {
|
||||
per_user_settings: true,
|
||||
new_instance_flag: false,
|
||||
new_instance_env: None,
|
||||
control: false,
|
||||
unreadable_paths: false,
|
||||
launch_options: &[],
|
||||
};
|
||||
let reason = skip_reason(&LaunchContext { launch_options: &with(&[("PHOTOCRAFT_PRESENT_MODE", "novsync")]), ..base });
|
||||
assert!(reason.as_deref().is_some_and(|r| r.contains("--present-mode")), "{reason:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
<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"
|
||||
>
|
||||
<!-- PhotoCraft's own Move tool options-bar glyph (not from an icon set). -->
|
||||
<path d="M3 20h18" />
|
||||
<rect x="5" y="4" width="5" height="12" rx="1" />
|
||||
<rect x="14" y="9" width="5" height="7" rx="1" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 416 B |
@@ -0,0 +1,16 @@
|
||||
<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"
|
||||
>
|
||||
<!-- PhotoCraft's own Move tool options-bar glyph (not from an icon set). -->
|
||||
<path d="M12 3v18" />
|
||||
<rect x="5" y="5" width="14" height="5" rx="1" />
|
||||
<rect x="8" y="14" width="8" height="5" rx="1" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 416 B |
@@ -0,0 +1,16 @@
|
||||
<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"
|
||||
>
|
||||
<!-- PhotoCraft's own Move tool options-bar glyph (not from an icon set). -->
|
||||
<path d="M4 3v18" />
|
||||
<rect x="8" y="5" width="12" height="5" rx="1" />
|
||||
<rect x="8" y="14" width="7" height="5" rx="1" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 415 B |
@@ -0,0 +1,16 @@
|
||||
<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"
|
||||
>
|
||||
<!-- PhotoCraft's own Move tool options-bar glyph (not from an icon set). -->
|
||||
<path d="M20 3v18" />
|
||||
<rect x="4" y="5" width="12" height="5" rx="1" />
|
||||
<rect x="9" y="14" width="7" height="5" rx="1" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 416 B |
@@ -0,0 +1,16 @@
|
||||
<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"
|
||||
>
|
||||
<!-- PhotoCraft's own Move tool options-bar glyph (not from an icon set). -->
|
||||
<path d="M3 4h18" />
|
||||
<rect x="5" y="8" width="5" height="12" rx="1" />
|
||||
<rect x="14" y="8" width="5" height="7" rx="1" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 415 B |
@@ -0,0 +1,16 @@
|
||||
<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"
|
||||
>
|
||||
<!-- PhotoCraft's own Move tool options-bar glyph (not from an icon set). -->
|
||||
<path d="M3 12h18" />
|
||||
<rect x="5" y="5" width="5" height="14" rx="1" />
|
||||
<rect x="14" y="8" width="5" height="8" rx="1" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 416 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"
|
||||
>
|
||||
<!-- PhotoCraft's own Move tool options-bar glyph (not from an icon set). -->
|
||||
<path d="M4 7h16" />
|
||||
<rect x="7" y="2" width="10" height="3" rx="1" />
|
||||
<path d="M4 14h16" />
|
||||
<rect x="7" y="9" width="10" height="3" rx="1" />
|
||||
<path d="M4 21h16" />
|
||||
<rect x="7" y="16" width="10" height="3" rx="1" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 516 B |
@@ -0,0 +1,22 @@
|
||||
<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"
|
||||
>
|
||||
<!-- PhotoCraft's own Move tool options-bar glyph (not from an icon set). -->
|
||||
<rect x="2" y="6" width="4" height="12" rx="1" />
|
||||
<path d="M4 2v2" />
|
||||
<path d="M4 20v2" />
|
||||
<rect x="10" y="6" width="4" height="12" rx="1" />
|
||||
<path d="M12 2v2" />
|
||||
<path d="M12 20v2" />
|
||||
<rect x="18" y="6" width="4" height="12" rx="1" />
|
||||
<path d="M20 2v2" />
|
||||
<path d="M20 20v2" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 585 B |
@@ -0,0 +1,16 @@
|
||||
<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"
|
||||
>
|
||||
<!-- PhotoCraft's own Move tool options-bar glyph (not from an icon set). -->
|
||||
<path d="M3 4v16" />
|
||||
<path d="M21 4v16" />
|
||||
<rect x="9" y="7" width="6" height="10" rx="1" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 387 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"
|
||||
>
|
||||
<!-- PhotoCraft's own Move tool options-bar glyph (not from an icon set). -->
|
||||
<path d="M3 4v16" />
|
||||
<rect x="5" y="7" width="3" height="10" rx="1" />
|
||||
<path d="M10 4v16" />
|
||||
<rect x="12" y="7" width="3" height="10" rx="1" />
|
||||
<path d="M17 4v16" />
|
||||
<rect x="19" y="7" width="3" height="10" rx="1" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 517 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"
|
||||
>
|
||||
<!-- PhotoCraft's own Move tool options-bar glyph (not from an icon set). -->
|
||||
<path d="M7 4v16" />
|
||||
<rect x="2" y="7" width="3" height="10" rx="1" />
|
||||
<path d="M14 4v16" />
|
||||
<rect x="9" y="7" width="3" height="10" rx="1" />
|
||||
<path d="M21 4v16" />
|
||||
<rect x="16" y="7" width="3" height="10" rx="1" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 516 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"
|
||||
>
|
||||
<!-- PhotoCraft's own Move tool options-bar glyph (not from an icon set). -->
|
||||
<path d="M4 3h16" />
|
||||
<rect x="7" y="5" width="10" height="3" rx="1" />
|
||||
<path d="M4 10h16" />
|
||||
<rect x="7" y="12" width="10" height="3" rx="1" />
|
||||
<path d="M4 17h16" />
|
||||
<rect x="7" y="19" width="10" height="3" rx="1" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 517 B |
@@ -0,0 +1,22 @@
|
||||
<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"
|
||||
>
|
||||
<!-- PhotoCraft's own Move tool options-bar glyph (not from an icon set). -->
|
||||
<rect x="6" y="2" width="12" height="4" rx="1" />
|
||||
<path d="M2 4h2" />
|
||||
<path d="M20 4h2" />
|
||||
<rect x="6" y="10" width="12" height="4" rx="1" />
|
||||
<path d="M2 12h2" />
|
||||
<path d="M20 12h2" />
|
||||
<rect x="6" y="18" width="12" height="4" rx="1" />
|
||||
<path d="M2 20h2" />
|
||||
<path d="M20 20h2" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 585 B |
@@ -0,0 +1,16 @@
|
||||
<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"
|
||||
>
|
||||
<!-- PhotoCraft's own Move tool options-bar glyph (not from an icon set). -->
|
||||
<path d="M4 3h16" />
|
||||
<path d="M4 21h16" />
|
||||
<rect x="7" y="9" width="10" height="6" rx="1" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 387 B |
@@ -0,0 +1,17 @@
|
||||
<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"
|
||||
>
|
||||
<!-- PhotoCraft's own Create Warped Text glyph (not from an icon set): a T above a bowed arc. -->
|
||||
<path d="M6 5V3h12v2" />
|
||||
<path d="M12 3v11" />
|
||||
<path d="M9.5 14h5" />
|
||||
<path d="M3 17c5 5 13 5 18 0" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 419 B |
@@ -8,8 +8,11 @@ tiles/<blake3>.zst
|
||||
blobs/<blake3>.zst
|
||||
thumb.png
|
||||
composite/preview.png
|
||||
save.lock
|
||||
```
|
||||
|
||||
`save.lock` exists only in directory bundles: a save holds an OS file lock on it while it writes objects, publishes the manifest and removes unreferenced objects, so concurrent saves of one bundle take turns. It is empty, and readers ignore it.
|
||||
|
||||
The manifest is versioned and describes the document tree. Tiles and blobs are content-addressed. Loaders verify ZIP entry bounds and CRCs; document content also uses stored hashes to detect mismatched or corrupted tile/blob content. Unsupported newer format versions fail explicitly.
|
||||
|
||||
## Implemented load limits
|
||||
|
||||
@@ -19,6 +19,7 @@ Every route that writes a document to a flat format (Save As, the CLI, MCP `doc_
|
||||
- Formats without transparency (JPEG, Radiance HDR) get the image composited over white, as Export As and Quick Export do; in CMYK, white is no ink.
|
||||
- Formats that can't embed an ICC profile (GIF, BMP, TGA, QOI, ICO, Netpbm/PFM) get RGB documents in another profile (linear light, Display P3, …) converted to sRGB through the colour engine (perceptual intent), since an untagged file is read as sRGB. OpenEXR and Radiance HDR get linear sRGB instead (linear gray for a grayscale OpenEXR), which is how they are tagged when opened.
|
||||
- TGA, as Photoshop's Targa: an RGB Targa is 24 or 32 bits per pixel. Save As and Save a Copy ask (Targa Options, starting at 32 with an alpha channel, else 24); Export As has a Resolution choice (Auto, 24 or 32 bits/pixel); scripts pass `tgaBits` (`--tga-bits` on the CLI). At 32 bits the document's alpha channel is the file's alpha when it has exactly one (spot channels don't count; with several, none is used), and the colours are written exactly, never premultiplied or cleared where the alpha is 0, so a game texture's mask and its colours both survive. Without a usable alpha channel the layers' transparency is the alpha (opaque without any). 24 bits write no alpha, compositing transparency over white. Without a choice: 32 with an alpha channel or transparency, else 24. A 32-bit Targa opens as an opaque Background with its alpha as "Alpha 1", so it round-trips. Grayscale Targas are 8-bit.
|
||||
- JPEG, as Photoshop's JPEG Options: File › Save, Save As and Save a Copy ask for the Quality on Photoshop's 0–12 scale (with its Low, Medium, High and Maximum presets) and a Format Option: Baseline ("Standard"), Baseline Optimized (Huffman tables fitted to the image) or Progressive. The quality starts at the opened JPEG's own quality, else at the Export preference's JPEG quality; left there, an opened JPEG is written at exactly its own quality. Scripts pass `"quality": 0..12` and `"jpegFormat": "baseline" | "optimized" | "progressive"` (on the CLI, `--quality` on the encoder's 1–100 scale and `--jpeg-format`) and aren't asked; recorded actions replay the answer. The scale maps onto the encoder's 1–100 as `round(q / 12 × 99 + 1)`. RGB JPEGs are written 4:2:0, where Baseline Optimized keeps the standard tables for now (Gray and CMYK, written 4:4:4, get fitted ones). Matte, the progressive scan count (Scans 3–5) and the file-size preview aren't offered.
|
||||
- DDS, for game and 3D textures: Save As and Save a Copy ask (DDS Options) for the compression (None, uncompressed and lossless; BC1/DXT1, whose alpha is on or off; BC2/DXT3; BC3/DXT5) and whether to generate mipmaps (the full chain down to 1×1, each level a box filter of the one above). Scripts pass `ddsCompression` (`none`, `bc1`, `bc2`, `bc3`) and `ddsMipmaps` (`--dds-compression`, `--dds-mipmaps` on the CLI). Opening one reads its top mip level.
|
||||
|
||||
Flat TIFF and JPEG exports of composited CMYK documents convert the RGB composite back through
|
||||
|
||||
@@ -17,6 +17,13 @@
|
||||
# To remove your names, delete your entry (or set fields empty) in a PR. The next
|
||||
# contributors.py run, and the next build, drop them.
|
||||
|
||||
[people.brianblakely]
|
||||
sync_github_name = true
|
||||
|
||||
[people.abdul-karim-mia]
|
||||
real_name = "Abdul karim mia"
|
||||
sync_github_name = false
|
||||
|
||||
[people.jonathanwxh-cell]
|
||||
display_name = "jonathnwxh"
|
||||
sync_github_name = false
|
||||
@@ -116,6 +123,9 @@ sync_github_name = false
|
||||
[people.axlecoffee]
|
||||
sync_github_name = true
|
||||
|
||||
[people.zi-ger]
|
||||
sync_github_name = true
|
||||
|
||||
[people.Losminek]
|
||||
sync_github_name = true
|
||||
|
||||
@@ -166,6 +176,9 @@ sync_github_name = true
|
||||
real_name = "Sidney Markowitz"
|
||||
sync_github_name = true
|
||||
|
||||
[people.theoabw]
|
||||
real_name = "Theo Wilenius"
|
||||
|
||||
[people.TaylorBurke]
|
||||
sync_github_name = true
|
||||
|
||||
@@ -191,3 +204,50 @@ sync_github_name = true
|
||||
|
||||
[people.ha1des]
|
||||
sync_github_name = true
|
||||
|
||||
[people.webdevtodayjason]
|
||||
display_name = "Jason Brashear"
|
||||
|
||||
[people.AndreeSalazar]
|
||||
real_name = "Andreé Salazar"
|
||||
sync_github_name = true
|
||||
|
||||
[people.LorenzoFasolino]
|
||||
real_name = "Lorenzo Fasolino"
|
||||
display_name = "Lorenzo Fasolino"
|
||||
sync_github_name = false
|
||||
|
||||
[people.zolyy]
|
||||
real_name = "Gall Zoltan"
|
||||
sync_github_name = false
|
||||
|
||||
[people.theshubh77]
|
||||
real_name = "Shubham Bhamare"
|
||||
sync_github_name = true
|
||||
|
||||
[people.ksaxena149]
|
||||
sync_github_name = true
|
||||
|
||||
[people.giang17]
|
||||
real_name = "Giang Nguyen"
|
||||
sync_github_name = true
|
||||
|
||||
[people.aqibmohd271]
|
||||
sync_github_name = true
|
||||
|
||||
[people.marcoarnulfo]
|
||||
real_name = "Marco Arnulfo"
|
||||
display_name = "Marco Arnulfo"
|
||||
sync_github_name = false
|
||||
|
||||
[people.Gotix-eg]
|
||||
real_name = "Ahmed Tammam"
|
||||
display_name = "Gotix"
|
||||
sync_github_name = true
|
||||
|
||||
[people.adi-1108]
|
||||
real_name = "Aditya Vishwakarma"
|
||||
sync_github_name = true
|
||||
|
||||
[people.benandez]
|
||||
real_name = "Martin Ball"
|
||||
|
||||
@@ -20,6 +20,15 @@ impl Stream {
|
||||
self.class(id) == Some(Tag::of(class))
|
||||
}
|
||||
|
||||
/// The raw slot count of an object's array field (including null slots), for charging
|
||||
/// traversal work before nulls are filtered out. `None` when the field is not an array.
|
||||
pub fn array_len(&self, id: ObjId, tag: &[u8; 4]) -> Option<usize> {
|
||||
match self.field(id, tag)? {
|
||||
Value::Array(a) => Some(a.len()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A child object (shared, linked or inline); `None` for null or missing.
|
||||
pub fn obj(&self, id: ObjId, tag: &[u8; 4]) -> Option<ObjId> {
|
||||
match self.field(id, tag)? {
|
||||
|
||||
@@ -210,7 +210,16 @@ fn anchors(records: &[Value]) -> Option<Vec<Anchor>> {
|
||||
fn subpath(anchors: &[Anchor], closed: bool, radii: Option<&Vec<(usize, f64)>>) -> Option<SubPath> {
|
||||
let first = anchors.first()?;
|
||||
if let Some(radii) = radii.filter(|r| !r.is_empty()) {
|
||||
let corner = |a: &Anchor| radii.iter().filter(|(rec, _)| *rec == a.record).map(|(_, r)| *r).fold(0.0, f64::max);
|
||||
// Index each record's maximum radius once. Scanning the whole radius list per anchor
|
||||
// was O(anchors * radii), which a file with large anchor and radius arrays could drive
|
||||
// into quadratic work. Duplicate record indices keep the maximum, matching the prior
|
||||
// fold(0.0, f64::max) behavior.
|
||||
let mut radius_by_record: std::collections::HashMap<usize, f64> = std::collections::HashMap::with_capacity(radii.len());
|
||||
for &(rec, r) in radii {
|
||||
let slot = radius_by_record.entry(rec).or_insert(0.0);
|
||||
*slot = slot.max(r);
|
||||
}
|
||||
let corner = |a: &Anchor| radius_by_record.get(&a.record).copied().unwrap_or(0.0);
|
||||
let polyline = anchors.iter().all(|a| a.inh.is_none_or(|h| h == a.p) && a.out.is_none_or(|h| h == a.p));
|
||||
if polyline {
|
||||
let mut pts: Vec<(Point, f64)> = anchors.iter().map(|a| (a.p, corner(a))).collect();
|
||||
|
||||
@@ -283,6 +283,12 @@ const MAX_TREE_DEPTH: usize = 128;
|
||||
/// Total layer nodes imported.
|
||||
const MAX_NODES: usize = 500_000;
|
||||
|
||||
/// Total raw `Chld` slots inspected across the whole traversal, including null slots and slots
|
||||
/// reached through repeated references to a shared group. The node limit only counts produced
|
||||
/// nodes, so a shared group holding a large null-filled child array could still be rescanned
|
||||
/// many times; this bounds that work. Generous relative to MAX_NODES for real documents.
|
||||
const MAX_CHILD_SLOTS: usize = 8_000_000;
|
||||
|
||||
/// Read a document: archive, `doc.dat` and the model.
|
||||
pub fn read(bytes: &[u8], limits: Limits) -> Result<Document, Error> {
|
||||
let mut archive = Archive::open(bytes, limits)?;
|
||||
@@ -297,6 +303,7 @@ pub(crate) struct Reader<'s, 'a, 'b> {
|
||||
pub(crate) dpi: f64,
|
||||
warnings: BTreeMap<String, usize>,
|
||||
nodes: usize,
|
||||
child_slots: usize,
|
||||
active: HashSet<ObjId>,
|
||||
pub(crate) geometry_budget: crate::geometry::Budget,
|
||||
/// Decoded pixels still available ([`Limits::max_pixel_bytes`] less what the layers read so
|
||||
@@ -312,6 +319,7 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
|
||||
dpi: 72.0,
|
||||
warnings: BTreeMap::new(),
|
||||
nodes: 0,
|
||||
child_slots: 0,
|
||||
active: HashSet::new(),
|
||||
geometry_budget: crate::geometry::Budget::default(),
|
||||
pixels_left: limits.max_pixel_bytes,
|
||||
@@ -383,6 +391,13 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
|
||||
let s = self.s;
|
||||
let mut out = Vec::new();
|
||||
let mut mask = None;
|
||||
// Charge every raw child slot (null slots included) against a document-wide budget
|
||||
// before filtering. A shared group with a large null-filled Chld array could otherwise
|
||||
// be rescanned through each reference without the node limit noticing.
|
||||
self.child_slots = self.child_slots.saturating_add(s.array_len(parent, b"Chld").unwrap_or(0));
|
||||
if self.child_slots > MAX_CHILD_SLOTS {
|
||||
return Err(Error::Limit("too many child slots in the layer tree"));
|
||||
}
|
||||
for c in s.objs(parent, b"Chld") {
|
||||
if s.is(c, b"MRst") {
|
||||
// A mask layer inside a group or layer masks that container.
|
||||
|
||||
@@ -73,11 +73,40 @@ pub struct Object {
|
||||
pub chain: Vec<Tag>,
|
||||
/// Fields in stream order; positional fields have tag 0.
|
||||
pub fields: Vec<(Tag, Value)>,
|
||||
/// First-occurrence index of each non-positional tag, so `get` is O(1). A shared object
|
||||
/// can be referenced by many sibling layers, and each reference used to rescan all of its
|
||||
/// fields; indexing once keeps repeated lookups from multiplying into quadratic work.
|
||||
index: std::collections::HashMap<Tag, usize>,
|
||||
}
|
||||
|
||||
impl Object {
|
||||
pub fn new(class: Tag, kind: Kind, chain: Vec<Tag>, fields: Vec<(Tag, Value)>) -> Self {
|
||||
let mut index = std::collections::HashMap::with_capacity(fields.len());
|
||||
for (i, (t, _)) in fields.iter().enumerate() {
|
||||
// Positional fields share tag 0 and are not looked up by tag; keep first-match
|
||||
// semantics for real tags.
|
||||
if t.0 != 0 {
|
||||
index.entry(*t).or_insert(i);
|
||||
}
|
||||
}
|
||||
Object { class, kind, chain, fields, index }
|
||||
}
|
||||
|
||||
pub fn get(&self, tag: Tag) -> Option<&Value> {
|
||||
self.fields.iter().find(|(t, _)| *t == tag).map(|(_, v)| v)
|
||||
self.index.get(&tag).map(|&i| &self.fields[i].1)
|
||||
}
|
||||
|
||||
/// Replaces the fields (the root object is filled in after it is pushed) and rebuilds the
|
||||
/// tag index so `get` stays correct.
|
||||
fn set_fields(&mut self, fields: Vec<(Tag, Value)>) {
|
||||
self.index.clear();
|
||||
self.index.reserve(fields.len());
|
||||
for (i, (t, _)) in fields.iter().enumerate() {
|
||||
if t.0 != 0 {
|
||||
self.index.entry(*t).or_insert(i);
|
||||
}
|
||||
}
|
||||
self.fields = fields;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,10 +153,10 @@ pub fn parse(bytes: &[u8]) -> Result<Stream, Error> {
|
||||
c.u32()?;
|
||||
}
|
||||
let mut p = Parser { c, objects: Vec::new(), shared: std::collections::HashMap::new(), depth: 0, values: 0 };
|
||||
let root = p.push(Object { class, kind: Kind::Root, chain: vec![class], fields: Vec::new() })?;
|
||||
let root = p.push(Object::new(class, Kind::Root, vec![class], Vec::new()))?;
|
||||
let fields = p.fields(true, class)?;
|
||||
if let Some(o) = p.objects.get_mut(root) {
|
||||
o.fields = fields;
|
||||
o.set_fields(fields);
|
||||
}
|
||||
Ok(Stream { root, objects: p.objects })
|
||||
}
|
||||
@@ -252,7 +281,7 @@ impl Parser<'_> {
|
||||
}
|
||||
0x30 => {
|
||||
let fields = self.fields(false, class)?;
|
||||
let id = self.push(Object { class, kind: Kind::Positional, chain: Vec::new(), fields })?;
|
||||
let id = self.push(Object::new(class, Kind::Positional, Vec::new(), fields))?;
|
||||
Value::Obj(Some(id))
|
||||
}
|
||||
0x31 | 0x32 if !tagged => return Err(Error::Malformed("object without a field tag")),
|
||||
@@ -350,7 +379,7 @@ impl Parser<'_> {
|
||||
}
|
||||
};
|
||||
let fields = self.fields(true, class)?;
|
||||
let id = self.push(Object { class, kind: Kind::Inline, chain: vec![class], fields })?;
|
||||
let id = self.push(Object::new(class, Kind::Inline, vec![class], fields))?;
|
||||
Ok(Value::Obj(Some(id)))
|
||||
}
|
||||
|
||||
@@ -369,7 +398,7 @@ impl Parser<'_> {
|
||||
return Err(Error::Malformed("object defined twice"));
|
||||
}
|
||||
// Register before the fields: an object's own fields may refer back to it.
|
||||
let id = self.push(Object { class: Tag(0), kind: Kind::Shared, chain: Vec::new(), fields: Vec::new() })?;
|
||||
let id = self.push(Object::new(Tag(0), Kind::Shared, Vec::new(), Vec::new()))?;
|
||||
self.shared.insert(key, id);
|
||||
let mut chain = Vec::new();
|
||||
let mut fields = Vec::new();
|
||||
@@ -395,7 +424,7 @@ impl Parser<'_> {
|
||||
let class = chain.first().copied().unwrap_or(Tag(0));
|
||||
fields.extend(self.fields(true, class)?);
|
||||
if let Some(o) = self.objects.get_mut(id) {
|
||||
*o = Object { class, kind: Kind::Shared, chain, fields };
|
||||
*o = Object::new(class, Kind::Shared, chain, fields);
|
||||
}
|
||||
Ok(Value::Obj(Some(id)))
|
||||
}
|
||||
|
||||
@@ -127,10 +127,23 @@ fn main() {
|
||||
photocraft_algo::puppet::ITERATIONS,
|
||||
t.elapsed().as_secs_f64() * 1e3
|
||||
);
|
||||
let dst: Vec<[f64; 2]> = pw.pins.iter().map(|p| p.dst).collect();
|
||||
let t = Instant::now();
|
||||
let _ = solver.solve(&dst, &[None; 3], PuppetMode::Normal, 4, Some(&v));
|
||||
println!("puppet interactive re-solve (4 warm rounds): {:.2} ms", t.elapsed().as_secs_f64() * 1e3);
|
||||
// Compare the old path-dependent preview with the command-equivalent drag solve, using
|
||||
// changing destinations and the same cached factorization (no remeshing per event).
|
||||
for (label, rounds, warm) in [("6 warm rounds", 6, true), ("canonical cold solve", photocraft_algo::puppet::ITERATIONS, false)] {
|
||||
let mut previous = v.clone();
|
||||
let mut times = Vec::new();
|
||||
for step in 1..=60 {
|
||||
let mut dst: Vec<[f64; 2]> = pw.pins.iter().map(|p| p.dst).collect();
|
||||
dst[1][0] -= f64::from(step) * 10.0;
|
||||
dst[1][1] += (f64::from(step) * 0.1).sin() * 400.0;
|
||||
let t = Instant::now();
|
||||
let (next, _) = solver.solve(&dst, &[None; 3], pw.mode, rounds, warm.then_some(previous.as_slice()));
|
||||
times.push(t.elapsed().as_secs_f64() * 1e3);
|
||||
previous = std::hint::black_box(next);
|
||||
}
|
||||
times.sort_by(f64::total_cmp);
|
||||
println!("puppet drag ({label}, 60 moves): median {:.2} ms, p95 {:.2} ms, max {:.2} ms", times[30], times[56], times[59]);
|
||||
}
|
||||
let t = Instant::now();
|
||||
let _ = render(&s, b, &solver.mesh, &v, &order, Interp::Bicubic);
|
||||
println!("puppet render 24 MP: {:.0} ms", t.elapsed().as_secs_f64() * 1e3);
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
//! copied untouched. [`ProxyImage::render`] is the cheap preview path: bilinear field and
|
||||
//! bilinear source on a downsampled RGBA copy, re-rendered only where a dab changed things.
|
||||
|
||||
use crate::par;
|
||||
use photocraft_geom::Rect;
|
||||
use photocraft_raster::Surface;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -186,6 +187,10 @@ struct StrokeCursor {
|
||||
carry: f64,
|
||||
}
|
||||
|
||||
/// Dabs over this many field nodes compute their rows in parallel (an 800 px brush has about
|
||||
/// 125,000); smaller ones are not worth the hand-off.
|
||||
const PARALLEL_DAB_NODES: usize = 16_384;
|
||||
|
||||
/// Distance between dabs along a stroke for brush radius `r`: a twentieth of the radius, so a
|
||||
/// drag is many small pushes and its edge stays smooth.
|
||||
fn dab_spacing(r: f64) -> f64 {
|
||||
@@ -487,9 +492,11 @@ impl LiquifyField {
|
||||
bilinear(&self.d, self.w, self.h, gx, gy)
|
||||
}
|
||||
};
|
||||
let mut writes: Vec<(usize, [f32; 2], f32)> = Vec::with_capacity((i1 - i0 + 1) * (j1 - j0 + 1));
|
||||
// Every node reads only the snapshot and the field as it was before this dab (the writes
|
||||
// land after), so rows are independent: a large brush computes them in parallel.
|
||||
let inv_cell = 1.0 / self.cell;
|
||||
for j in j0..=j1 {
|
||||
let node_row = |j: usize| -> Vec<(usize, [f32; 2], f32)> {
|
||||
let mut row = Vec::with_capacity(i1 - i0 + 1);
|
||||
for i in i0..=i1 {
|
||||
let p = self.node_pos(i, j);
|
||||
let (dx, dy) = (p[0] - c[0], p[1] - c[1]);
|
||||
@@ -503,7 +510,7 @@ impl LiquifyField {
|
||||
if s.tool == LiquifyTool::Freeze || s.tool == LiquifyTool::Thaw {
|
||||
let f = self.freeze[idx];
|
||||
let v = if s.tool == LiquifyTool::Freeze { (f as f64 + k_raw).min(1.0) } else { (f as f64 - k_raw).max(0.0) };
|
||||
writes.push((idx, self.d[idx], v as f32));
|
||||
row.push((idx, self.d[idx], v as f32));
|
||||
continue;
|
||||
}
|
||||
let k = k_raw * (1.0 - f64::from(self.freeze[idx]));
|
||||
@@ -553,10 +560,13 @@ impl LiquifyField {
|
||||
[(q[0] + dq[0] - p[0]) as f32, (q[1] + dq[1] - p[1]) as f32]
|
||||
}
|
||||
};
|
||||
writes.push((idx, new, self.freeze[idx]));
|
||||
row.push((idx, new, self.freeze[idx]));
|
||||
}
|
||||
}
|
||||
for (idx, d, f) in writes {
|
||||
row
|
||||
};
|
||||
let rows = j1 - j0 + 1;
|
||||
let writes = par::map(rows, rows * (i1 - i0 + 1) > PARALLEL_DAB_NODES, |row| node_row(j0 + row));
|
||||
for (idx, d, f) in writes.into_iter().flatten() {
|
||||
self.d[idx] = d;
|
||||
self.freeze[idx] = f;
|
||||
}
|
||||
@@ -653,7 +663,9 @@ pub fn apply_liquify(src: &Surface, field: &LiquifyField) -> Surface {
|
||||
if field.is_identity() || b.is_empty() {
|
||||
return out;
|
||||
}
|
||||
let maxd = f64::from(field.max_displacement()).ceil() as i32 + 3;
|
||||
// A 1D Catmull-Rom stencil's absolute weights sum to at most 5/4, so the 2D
|
||||
// interpolated displacement can reach 25/16 of the largest nodal displacement.
|
||||
let maxd = ((f64::from(field.max_displacement()) * (25.0 / 16.0)).ceil() as i32).saturating_add(3);
|
||||
let has_freeze = field.freeze.iter().any(|&f| f > 0.0);
|
||||
let tiles: Vec<Rect> = b.tiles().map(|t| t.rect().intersect(&b)).filter(|r| !r.is_empty() && field.touches(*r)).collect();
|
||||
let work = |t: &Rect| -> Option<(Rect, Vec<f32>)> {
|
||||
@@ -929,18 +941,23 @@ impl ProxyImage {
|
||||
return false;
|
||||
}
|
||||
source_scratch.resize(source_len, [0.0; 4]);
|
||||
src.read_rgba_into(src_rect, source_scratch);
|
||||
if !src.format().alpha {
|
||||
for pixel in source_scratch.iter_mut() {
|
||||
pixel[3] = 1.0;
|
||||
// Read and premultiply the source in one pass per band of rows, the bands in parallel: at
|
||||
// an 800 px brush it is about a million pixels (P30).
|
||||
let opaque = !src.format().alpha;
|
||||
const BAND_ROWS: usize = 32;
|
||||
par::chunks_mut(source_scratch, sw * BAND_ROWS, true, |band_index, band| {
|
||||
let y = src_rect.y0 + (band_index * BAND_ROWS) as i32;
|
||||
src.read_rgba_into(Rect::new(src_rect.x0, y, src_rect.x1, y + (band.len() / sw) as i32), band);
|
||||
for pixel in band.iter_mut() {
|
||||
if opaque {
|
||||
pixel[3] = 1.0;
|
||||
}
|
||||
let alpha = pixel[3];
|
||||
pixel[0] *= alpha;
|
||||
pixel[1] *= alpha;
|
||||
pixel[2] *= alpha;
|
||||
}
|
||||
}
|
||||
for pixel in source_scratch.iter_mut() {
|
||||
let alpha = pixel[3];
|
||||
pixel[0] *= alpha;
|
||||
pixel[1] *= alpha;
|
||||
pixel[2] *= alpha;
|
||||
}
|
||||
});
|
||||
|
||||
let x0 = (region.x0 - bounds.x0) as usize;
|
||||
let x1 = (region.x1 - bounds.x0) as usize;
|
||||
|
||||
@@ -124,6 +124,179 @@ pub(crate) fn hsb_hsl(src: &Image, out: Rect, ctx: &Ctx, input: HsbModel, output
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use photocraft_color::{PixelFormat, SampleType};
|
||||
use photocraft_raster::Surface;
|
||||
|
||||
const DEPTHS: [SampleType; 3] = [SampleType::U8, SampleType::U16, SampleType::F32];
|
||||
|
||||
fn custom_fixture(depth: SampleType, mode: ColorMode, alpha: bool) -> (Surface, Rect) {
|
||||
let b = Rect::new(-2, -1, 3, 4);
|
||||
let format = PixelFormat::new(mode, depth, alpha);
|
||||
let mut src = Surface::new(format);
|
||||
let mut pixels = Vec::new();
|
||||
for y in b.y0..b.y1 {
|
||||
for x in b.x0..b.x1 {
|
||||
for c in 0..format.channels() {
|
||||
pixels.push(((x - b.x0) + 5 * (y - b.y0) + 3 * c as i32) as f32 / 40.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
src.write_region(b, &pixels);
|
||||
(src, b)
|
||||
}
|
||||
|
||||
fn assert_sample(actual: f32, expected: f32, depth: SampleType) {
|
||||
let (expected, tolerance) = match depth {
|
||||
SampleType::U8 => (expected.clamp(0.0, 1.0), 1.0 / 255.0),
|
||||
SampleType::U16 => (expected.clamp(0.0, 1.0), 1.0 / 65535.0),
|
||||
SampleType::F32 => (expected, 1e-6),
|
||||
};
|
||||
assert!((actual - expected).abs() <= tolerance, "{depth:?}: {actual} != {expected}");
|
||||
}
|
||||
|
||||
fn run_custom(src: &Surface, b: Rect, kernel: Vec<f32>, scale: f32, offset: f32) -> Surface {
|
||||
crate::apply_in(src, &crate::FilterParams::Custom { kernel, scale, offset }, b, b, None, b)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_all_25_taps_use_row_major_offsets_and_repeat_canvas_edges() {
|
||||
for depth in DEPTHS {
|
||||
let (src, b) = custom_fixture(depth, ColorMode::Rgb, true);
|
||||
for tap in 0..25 {
|
||||
let mut kernel = vec![0.0; 25];
|
||||
kernel[tap] = 1.0;
|
||||
let out = run_custom(&src, b, kernel, 1.0, 0.0);
|
||||
let (dx, dy) = (tap as i32 % 5 - 2, tap as i32 / 5 - 2);
|
||||
for y in b.y0..b.y1 {
|
||||
for x in b.x0..b.x1 {
|
||||
let expected = src.pixel((x + dx).clamp(b.x0, b.x1 - 1), (y + dy).clamp(b.y0, b.y1 - 1));
|
||||
let actual = out.pixel(x, y);
|
||||
for c in 0..3 {
|
||||
assert_sample(actual[c], expected[c], depth);
|
||||
}
|
||||
assert_eq!(actual[3], src.pixel(x, y)[3], "tap={tap}, alpha must not shift");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_short_kernel_zero_pads_and_ignores_entries_after_25() {
|
||||
for depth in DEPTHS {
|
||||
let (src, b) = custom_fixture(depth, ColorMode::Rgb, true);
|
||||
let mut short = vec![0.0; 13];
|
||||
short[0] = 0.25;
|
||||
short[12] = 0.75;
|
||||
let mut padded = short.clone();
|
||||
padded.resize(25, 0.0);
|
||||
let mut long = padded.clone();
|
||||
long.extend([1e6, -1e6, f32::NAN]);
|
||||
let out = run_custom(&src, b, short, 1.0, 0.0);
|
||||
assert_eq!(out.read_region(b), run_custom(&src, b, padded, 1.0, 0.0).read_region(b));
|
||||
assert_eq!(out.read_region(b), run_custom(&src, b, long, 1.0, 0.0).read_region(b));
|
||||
for y in b.y0..b.y1 {
|
||||
for x in b.x0..b.x1 {
|
||||
let corner = src.pixel((x - 2).max(b.x0), (y - 2).max(b.y0));
|
||||
let centre = src.pixel(x, y);
|
||||
let actual = out.pixel(x, y);
|
||||
for c in 0..3 {
|
||||
assert_sample(actual[c], 0.25 * corner[c] + 0.75 * centre[c], depth);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_scale_zero_is_one_and_offset_is_in_255ths() {
|
||||
for depth in DEPTHS {
|
||||
let (src, b) = custom_fixture(depth, ColorMode::Rgb, true);
|
||||
let before = src.read_region(b);
|
||||
let mut kernel = vec![0.0; 25];
|
||||
kernel[12] = 2.0;
|
||||
let out = run_custom(&src, b, kernel.clone(), 0.0, -51.0);
|
||||
assert_eq!(out.read_region(b), run_custom(&src, b, kernel, 1.0, -51.0).read_region(b));
|
||||
for y in b.y0..b.y1 {
|
||||
for x in b.x0..b.x1 {
|
||||
let original = src.pixel(x, y);
|
||||
let actual = out.pixel(x, y);
|
||||
for c in 0..3 {
|
||||
assert_sample(actual[c], 2.0 * original[c] - 0.2, depth);
|
||||
}
|
||||
assert_eq!(actual[3], original[3]);
|
||||
}
|
||||
}
|
||||
assert_eq!(src.read_region(b), before);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_neighbourhood_convolution_preserves_each_pixels_alpha() {
|
||||
for depth in DEPTHS {
|
||||
for mode in [ColorMode::Rgb, ColorMode::Cmyk] {
|
||||
let (src, b) = custom_fixture(depth, mode, true);
|
||||
let out = run_custom(&src, b, vec![1.0; 25], 25.0, 0.0);
|
||||
let alpha = src.channels() - 1;
|
||||
assert_ne!(out.pixel(b.x0, b.y0)[0], src.pixel(b.x0, b.y0)[0], "fixture must change colour");
|
||||
for y in b.y0..b.y1 {
|
||||
for x in b.x0..b.x1 {
|
||||
assert_eq!(out.pixel(x, y)[alpha], src.pixel(x, y)[alpha], "{depth:?}, {mode:?}, {x},{y}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_cmyk_convolves_brightness_including_black_with_or_without_alpha() {
|
||||
for depth in DEPTHS {
|
||||
for alpha in [false, true] {
|
||||
let (src, b) = custom_fixture(depth, ColorMode::Cmyk, alpha);
|
||||
for offset in [-51.0, 51.0] {
|
||||
let mut kernel = vec![0.0; 25];
|
||||
kernel[12] = 2.0;
|
||||
let out = run_custom(&src, b, kernel, 1.0, offset);
|
||||
for y in b.y0..b.y1 {
|
||||
for x in b.x0..b.x1 {
|
||||
let original = src.pixel(x, y);
|
||||
let actual = out.pixel(x, y);
|
||||
for c in 0..4 {
|
||||
// Twice the reflected brightness, plus offset, then back to ink.
|
||||
assert_sample(actual[c], 2.0 * original[c] - 1.0 - offset / 255.0, depth);
|
||||
}
|
||||
if alpha {
|
||||
assert_eq!(actual[4], original[4]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_float_retains_hdr_while_integer_storage_clamps() {
|
||||
let b = Rect::new(0, 0, 1, 1);
|
||||
for depth in DEPTHS {
|
||||
let mut src = Surface::new(PixelFormat::new(ColorMode::Rgb, depth, true));
|
||||
src.fill_rect(b, &[0.0, 0.5, 1.0, 0.4]);
|
||||
let mut kernel = vec![0.0; 25];
|
||||
kernel[12] = 2.0;
|
||||
let out = run_custom(&src, b, kernel, 1.0, -51.0);
|
||||
let actual = out.pixel(0, 0);
|
||||
let original = src.pixel(0, 0);
|
||||
for c in 0..3 {
|
||||
assert_sample(actual[c], 2.0 * original[c] - 0.2, depth);
|
||||
}
|
||||
assert_eq!(actual[3], original[3]);
|
||||
if depth == SampleType::F32 {
|
||||
assert!(actual[0] < 0.0 && actual[2] > 1.0);
|
||||
} else {
|
||||
assert_eq!((actual[0], actual[2]), (0.0, 1.0));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hsb_hsl_round_trip() {
|
||||
|
||||
@@ -897,14 +897,39 @@ pub fn seam_labels(w: usize, h: usize, luma: &[Vec<f32>], cover: &[Vec<bool>], o
|
||||
label[i] = img as i32;
|
||||
}
|
||||
}
|
||||
if no == 0 || nn == no {
|
||||
if no == 0 {
|
||||
continue;
|
||||
}
|
||||
let (ox, oy) = (sx / no as f64, sy / no as f64);
|
||||
let (cx, cy) = (nx / nn as f64, ny / nn as f64);
|
||||
let contained = nn == no;
|
||||
let (ox, oy) = if contained {
|
||||
// Wholly inside the composite (#5745: two photos in one document, the second moved
|
||||
// by Auto-Align so the canvas crops it to the part over the first). Its side of the
|
||||
// seam is the one it lies towards from the composite's centre; an image centred on
|
||||
// the composite has no side, and the earlier images keep it.
|
||||
let (mut px, mut py, mut pn) = (0.0f64, 0.0f64, 0usize);
|
||||
for (i, l) in label.iter().enumerate() {
|
||||
if *l >= 0 {
|
||||
px += (i % w) as f64;
|
||||
py += (i / w) as f64;
|
||||
pn += 1;
|
||||
}
|
||||
}
|
||||
let (px, py) = (px / pn.max(1) as f64, py / pn.max(1) as f64);
|
||||
if (cx - px).abs() < 0.5 && (cy - py).abs() < 0.5 {
|
||||
continue;
|
||||
}
|
||||
(px, py)
|
||||
} else {
|
||||
(sx / no as f64, sy / no as f64)
|
||||
};
|
||||
let vertical = (cx - ox).abs() >= (cy - oy).abs();
|
||||
// Image on the increasing side along the seam's cross axis?
|
||||
let positive = if vertical { cx > ox } else { cy > oy };
|
||||
// A contained image has no pixels of its own, so a seam on its outer edge would leave it
|
||||
// nothing: break cost ties away from its side (towards larger k when it lies on the
|
||||
// decreasing side) so it keeps its side of the overlap.
|
||||
let prefer_high = contained && !positive;
|
||||
let cost = |i: usize| -> f32 { 0.002 + (luma[img][i] - luma[label[i] as usize][i]).abs() };
|
||||
let (lines, len) = if vertical { (h, w) } else { (w, h) };
|
||||
let idx = |line: usize, k: usize| if vertical { line * w + k } else { k * w + line };
|
||||
@@ -925,7 +950,8 @@ pub fn seam_labels(w: usize, h: usize, luma: &[Vec<f32>], cover: &[Vec<bool>], o
|
||||
let mut best = (0.0f32, usize::MAX);
|
||||
if let Some(pl) = prev_line.filter(|pl| *pl + 1 == line) {
|
||||
best = (f32::INFINITY, usize::MAX);
|
||||
for dk in [-1i64, 0, 1] {
|
||||
let dks = if prefer_high { [1i64, 0, -1] } else { [-1i64, 0, 1] };
|
||||
for dk in dks {
|
||||
let kk = k as i64 + dk;
|
||||
if kk < 0 || kk >= len as i64 {
|
||||
continue;
|
||||
@@ -952,7 +978,12 @@ pub fn seam_labels(w: usize, h: usize, luma: &[Vec<f32>], cover: &[Vec<bool>], o
|
||||
if seam[line] != usize::MAX {
|
||||
continue;
|
||||
}
|
||||
let Some(k) = (0..len).filter(|&k| overlap[idx(line, k)]).min_by(|&a, &b| acc[idx(line, a)].total_cmp(&acc[idx(line, b)])) else { continue };
|
||||
let Some(k) = (0..len)
|
||||
.filter(|&k| overlap[idx(line, k)])
|
||||
.min_by(|&a, &b| acc[idx(line, a)].total_cmp(&acc[idx(line, b)]).then(if prefer_high { b.cmp(&a) } else { a.cmp(&b) }))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let (mut l, mut k) = (line, k);
|
||||
loop {
|
||||
seam[l] = k;
|
||||
@@ -972,9 +1003,13 @@ pub fn seam_labels(w: usize, h: usize, luma: &[Vec<f32>], cover: &[Vec<bool>], o
|
||||
if s == usize::MAX {
|
||||
continue;
|
||||
}
|
||||
let on_side = |k: usize| if positive { k > s } else { k < s };
|
||||
// A contained image has no other cells, so where the overlap has nothing on its side
|
||||
// of the seam (a line one cell wide) it takes the seam cell rather than nothing.
|
||||
let take_seam = contained && !(0..len).any(|k| overlap[idx(line, k)] && on_side(k));
|
||||
for k in 0..len {
|
||||
let i = idx(line, k);
|
||||
if overlap[i] && ((positive && k > s) || (!positive && k < s)) {
|
||||
if overlap[i] && (on_side(k) || (take_seam && k == s)) {
|
||||
label[i] = img as i32;
|
||||
}
|
||||
}
|
||||
@@ -1585,6 +1620,58 @@ mod tests {
|
||||
assert!(max_jump < 0.1, "max step {max_jump}");
|
||||
assert!(cov.iter().all(|c| *c == 1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_image_wholly_inside_the_composite_still_gets_its_side() {
|
||||
// #5745: the first image covers the whole 120×40 grid, the second only its right part
|
||||
// (x ≥ 70), all of it overlap. It used to be skipped, so the first image kept everything.
|
||||
let (w, h) = (120usize, 40usize);
|
||||
let luma = vec![vec![0.5f32; w * h], vec![0.5f32; w * h]];
|
||||
let cover: Vec<Vec<bool>> = vec![vec![true; w * h], (0..w * h).map(|i| i % w >= 70).collect()];
|
||||
let labels = seam_labels(w, h, &luma, &cover, &[0, 1]);
|
||||
for y in 0..h {
|
||||
assert_eq!(labels[y * w + 5], 0, "row {y}");
|
||||
assert_eq!(labels[y * w + w - 1], 1, "row {y}");
|
||||
// The second image's region is one run at the right of its seam.
|
||||
let row = &labels[y * w..(y + 1) * w];
|
||||
let first = row.iter().position(|l| *l == 1).unwrap();
|
||||
assert!(first >= 70 && row[first..].iter().all(|l| *l == 1), "row {y}: {row:?}");
|
||||
}
|
||||
// The same on the left (x < 50) and on top (y < 15): the seam stays inside the overlap
|
||||
// instead of on the image's outer edge, which used to leave it nothing.
|
||||
let left: Vec<Vec<bool>> = vec![vec![true; w * h], (0..w * h).map(|i| i % w < 50).collect()];
|
||||
let labels = seam_labels(w, h, &luma, &left, &[0, 1]);
|
||||
for y in 0..h {
|
||||
let row = &labels[y * w..(y + 1) * w];
|
||||
assert_eq!(row[0], 1, "row {y}: {row:?}");
|
||||
assert_eq!(row[w - 1], 0, "row {y}");
|
||||
let last = row.iter().rposition(|l| *l == 1).unwrap();
|
||||
assert!(last < 50 && row[..=last].iter().all(|l| *l == 1), "row {y}: {row:?}");
|
||||
}
|
||||
let (tw, th) = (40usize, 120usize);
|
||||
let tluma = vec![vec![0.5f32; tw * th], vec![0.5f32; tw * th]];
|
||||
let top: Vec<Vec<bool>> = vec![vec![true; tw * th], (0..tw * th).map(|i| i / tw < 50).collect()];
|
||||
let labels = seam_labels(tw, th, &tluma, &top, &[0, 1]);
|
||||
for x in 0..tw {
|
||||
assert_eq!(labels[x], 1, "column {x}");
|
||||
assert_eq!(labels[(th - 1) * tw + x], 0, "column {x}");
|
||||
}
|
||||
// One column or row wide: the seam can only run through it, and the image keeps it.
|
||||
for col in [0, w - 1] {
|
||||
let strip: Vec<Vec<bool>> = vec![vec![true; w * h], (0..w * h).map(|i| i % w == col).collect()];
|
||||
let labels = seam_labels(w, h, &luma, &strip, &[0, 1]);
|
||||
assert!(labels.iter().enumerate().all(|(i, l)| *l == i32::from(i % w == col)), "column {col}: {labels:?}");
|
||||
}
|
||||
let strip: Vec<Vec<bool>> = vec![vec![true; tw * th], (0..tw * th).map(|i| i / tw == 0).collect()];
|
||||
let labels = seam_labels(tw, th, &tluma, &strip, &[0, 1]);
|
||||
assert!(labels.iter().enumerate().all(|(i, l)| *l == i32::from(i / tw == 0)), "{labels:?}");
|
||||
// An image centred on the composite has no side: the first image keeps the overlap.
|
||||
let centred: Vec<Vec<bool>> = vec![vec![true; w * h], (0..w * h).map(|i| (20..100).contains(&(i % w))).collect()];
|
||||
assert!(seam_labels(w, h, &luma, ¢red, &[0, 1]).iter().all(|l| *l == 0));
|
||||
// Two images covering the same cells, likewise.
|
||||
let same = vec![vec![true; w * h]; 2];
|
||||
assert!(seam_labels(w, h, &luma, &same, &[0, 1]).iter().all(|l| *l == 0));
|
||||
}
|
||||
}
|
||||
|
||||
/// The row-parallel `reduce` / `expand` / `push_pull` / `multiband` against the serial code they
|
||||
|
||||
@@ -61,8 +61,15 @@ pub enum Dither {
|
||||
Noise,
|
||||
}
|
||||
|
||||
/// `(v.clamp(0, 1) * 255).round() as u8`, without the libm `roundf` call per channel (that call
|
||||
/// was about a tenth of the palette scan on a large image). `x` is in 0..=255, so truncating
|
||||
/// with an integer cast and adding one when the exact remainder is at least a half is
|
||||
/// round-half-away-from-zero, the same as `f32::round`; NaN gives 0 in both. The
|
||||
/// `to8_matches_round_for_every_float` test checks every input.
|
||||
fn to8(v: f32) -> u8 {
|
||||
(v.clamp(0.0, 1.0) * 255.0).round() as u8
|
||||
let x = v.clamp(0.0, 1.0) * 255.0;
|
||||
let t = x as u32;
|
||||
(t + u32::from(x - t as f32 >= 0.5)) as u8
|
||||
}
|
||||
|
||||
fn rgb8(p: &[f32; 4]) -> Rgb8 {
|
||||
@@ -166,15 +173,116 @@ fn forced_colors(f: Forced) -> Vec<Rgb8> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Unique 8-bit colours with counts (opaque pixels only).
|
||||
/// Unique 8-bit colours with counts (opaque pixels only), sorted by colour.
|
||||
fn histogram(px: &[[f32; 4]]) -> Vec<(Rgb8, u32)> {
|
||||
let mut m: std::collections::HashMap<Rgb8, u32> = std::collections::HashMap::new();
|
||||
for p in px.iter().filter(|p| p[3] >= 0.5) {
|
||||
*m.entry(rgb8(p)).or_default() += 1;
|
||||
// Pack each opaque pixel into one u32 (r, g, b from the top, so the numeric order is the
|
||||
// `[u8; 3]` order), sort the plain integers and count the runs. This replaces a SipHash
|
||||
// `HashMap` insert per pixel: counts are integers, so any order of work gives the same
|
||||
// result, and equal colours sit next to each other after the sort. One 4-byte value per
|
||||
// pixel is the only large allocation (no per-thread tables).
|
||||
let pack = |p: &[f32; 4]| {
|
||||
let [r, g, b] = rgb8(p);
|
||||
u32::from(r) << 16 | u32::from(g) << 8 | u32::from(b)
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let keys = {
|
||||
use rayon::prelude::*;
|
||||
let mut keys: Vec<u32> = px.par_iter().filter(|p| p[3] >= 0.5).map(pack).collect();
|
||||
keys.par_sort_unstable();
|
||||
keys
|
||||
};
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let keys = {
|
||||
let mut keys: Vec<u32> = px.iter().filter(|p| p[3] >= 0.5).map(pack).collect();
|
||||
keys.sort_unstable();
|
||||
keys
|
||||
};
|
||||
let mut out: Vec<(Rgb8, u32)> = Vec::new();
|
||||
for k in keys {
|
||||
let c = [(k >> 16) as u8, (k >> 8) as u8, k as u8];
|
||||
match out.last_mut() {
|
||||
Some((last, n)) if *last == c => *n = n.saturating_add(1),
|
||||
_ => out.push((c, 1)),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Widest axis of `b` and its split score (weighted extent), or `None` for a box that cannot
|
||||
/// be split. Every score is bit for bit what the old per-iteration scan gave.
|
||||
fn split_score(b: &[([f32; 3], u32)]) -> Option<(usize, f32)> {
|
||||
if b.len() <= 1 {
|
||||
return None;
|
||||
}
|
||||
// One pass per axis, then the weight sum: the same folds the old per-iteration scan ran
|
||||
// (measured faster than a single fused pass, which the compiler does not vectorise).
|
||||
let mut best = (0usize, -1.0f32);
|
||||
for a in 0..3 {
|
||||
let (lo, hi) = b.iter().fold((f32::MAX, f32::MIN), |(lo, hi), p| (lo.min(p.0[a]), hi.max(p.0[a])));
|
||||
if hi - lo > best.1 {
|
||||
best = (a, hi - lo);
|
||||
}
|
||||
}
|
||||
let w: f32 = b.iter().map(|p| p.1 as f32).sum();
|
||||
Some((best.0, best.1 * w.sqrt()))
|
||||
}
|
||||
|
||||
/// Maps an f32 to a u32 whose unsigned order is exactly `f32::total_cmp` order.
|
||||
fn total_order_key(v: f32) -> u32 {
|
||||
let bits = v.to_bits();
|
||||
// Negative: flip every bit (reverses their order, puts them below). Positive: set the sign bit.
|
||||
bits ^ ((((bits as i32) >> 31) as u32) | 0x8000_0000)
|
||||
}
|
||||
|
||||
/// Stable LSD radix sort of `b` along `axis`, in the order of `f32::total_cmp`. Stable like
|
||||
/// `sort_by`, so ties keep their original order and the result is element for element the same;
|
||||
/// it is several times faster than a comparison sort on millions of points.
|
||||
fn radix_sort_along(b: &mut Vec<([f32; 3], u32)>, axis: usize) {
|
||||
let n = b.len();
|
||||
let Some(&fill) = b.first() else { return };
|
||||
if n < 2 {
|
||||
return;
|
||||
}
|
||||
// Three digits of 11, 11 and 10 bits; count all of them in one read.
|
||||
let mut counts = [[0u32; 2048]; 3];
|
||||
for e in b.iter() {
|
||||
let k = total_order_key(e.0[axis]);
|
||||
counts[0][(k & 0x7ff) as usize] += 1;
|
||||
counts[1][((k >> 11) & 0x7ff) as usize] += 1;
|
||||
counts[2][(k >> 22) as usize] += 1;
|
||||
}
|
||||
let mut src = std::mem::take(b);
|
||||
let mut dst = vec![fill; n];
|
||||
for (pass, count) in counts.iter_mut().enumerate() {
|
||||
// A digit that is the same for every point would not move anything.
|
||||
if count.iter().any(|&c| c as usize == n) {
|
||||
continue;
|
||||
}
|
||||
let mut sum = 0u32;
|
||||
for c in count.iter_mut() {
|
||||
let here = *c;
|
||||
*c = sum;
|
||||
sum += here;
|
||||
}
|
||||
let shift = 11 * pass as u32;
|
||||
for e in &src {
|
||||
let d = ((total_order_key(e.0[axis]) >> shift) & 0x7ff) as usize;
|
||||
dst[count[d] as usize] = *e;
|
||||
count[d] += 1;
|
||||
}
|
||||
std::mem::swap(&mut src, &mut dst);
|
||||
}
|
||||
*b = src;
|
||||
}
|
||||
|
||||
/// Stable sort of `b` along `axis`. Boxes this small are quicker with `sort_by` than with the
|
||||
/// radix sort's fixed counting cost.
|
||||
fn sort_along(b: &mut Vec<([f32; 3], u32)>, axis: usize) {
|
||||
if b.len() >= 256 {
|
||||
radix_sort_along(b, axis);
|
||||
} else {
|
||||
b.sort_by(|p, q| p.0[axis].total_cmp(&q.0[axis]));
|
||||
}
|
||||
let mut v: Vec<(Rgb8, u32)> = m.into_iter().collect();
|
||||
v.sort_unstable();
|
||||
v
|
||||
}
|
||||
|
||||
/// Median cut over `pts` (feature vectors + weights) into at most `n` boxes; returns box means.
|
||||
@@ -183,29 +291,23 @@ fn median_cut(pts: &[([f32; 3], u32)], n: usize) -> Vec<[f32; 3]> {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut boxes: Vec<Vec<([f32; 3], u32)>> = vec![pts.to_vec()];
|
||||
// Each box's (axis, score), kept in lockstep with `boxes` (same swap_remove and pushes).
|
||||
// Only the two boxes made by a split change, so only those are scanned again; rescanning
|
||||
// every box on every iteration was most of the time here.
|
||||
let mut scores: Vec<Option<(usize, f32)>> = vec![split_score(pts)];
|
||||
while boxes.len() < n {
|
||||
// Split the box with the largest weighted extent.
|
||||
let (bi, axis, score) = boxes
|
||||
// Split the box with the largest weighted extent (first one wins a tie, as before).
|
||||
let (bi, axis, score) = scores
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, b)| b.len() > 1)
|
||||
.map(|(i, b)| {
|
||||
let mut best = (0usize, -1.0f32);
|
||||
for a in 0..3 {
|
||||
let (lo, hi) = b.iter().fold((f32::MAX, f32::MIN), |(lo, hi), p| (lo.min(p.0[a]), hi.max(p.0[a])));
|
||||
if hi - lo > best.1 {
|
||||
best = (a, hi - lo);
|
||||
}
|
||||
}
|
||||
let w: f32 = b.iter().map(|p| p.1 as f32).sum();
|
||||
(i, best.0, best.1 * w.sqrt())
|
||||
})
|
||||
.filter_map(|(i, s)| s.map(|(axis, score)| (i, axis, score)))
|
||||
.fold((usize::MAX, 0, -1.0f32), |acc, x| if x.2 > acc.2 { x } else { acc });
|
||||
if bi == usize::MAX || score <= 0.0 {
|
||||
break;
|
||||
}
|
||||
let mut b = boxes.swap_remove(bi);
|
||||
b.sort_by(|p, q| p.0[axis].total_cmp(&q.0[axis]));
|
||||
scores.swap_remove(bi);
|
||||
sort_along(&mut b, axis);
|
||||
let total: u64 = b.iter().map(|p| u64::from(p.1)).sum();
|
||||
let mut acc = 0u64;
|
||||
let mut cut = 1;
|
||||
@@ -217,6 +319,8 @@ fn median_cut(pts: &[([f32; 3], u32)], n: usize) -> Vec<[f32; 3]> {
|
||||
}
|
||||
}
|
||||
let rest = b.split_off(cut);
|
||||
scores.push(split_score(&b));
|
||||
scores.push(split_score(&rest));
|
||||
boxes.push(b);
|
||||
boxes.push(rest);
|
||||
}
|
||||
@@ -239,8 +343,18 @@ fn kmeans(pts: &[([f32; 3], u32)], centers: &mut [[f32; 3]], iters: usize) {
|
||||
let lookup = Nearest::new(centers.iter().copied());
|
||||
let mut sum = vec![[0.0f64; 3]; centers.len()];
|
||||
let mut cnt = vec![0.0f64; centers.len()];
|
||||
for (p, w) in pts {
|
||||
let i = lookup.find(*p);
|
||||
// The nearest-centre search is the expensive part and has no cross-point state, so it
|
||||
// runs in parallel. The f64 sums below stay sequential in the original point order:
|
||||
// float addition is not associative, and this keeps every bit of the result unchanged.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let nearest: Vec<u32> = {
|
||||
use rayon::prelude::*;
|
||||
pts.par_iter().map(|(p, _)| lookup.find(*p) as u32).collect()
|
||||
};
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let nearest: Vec<u32> = pts.iter().map(|(p, _)| lookup.find(*p) as u32).collect();
|
||||
for ((p, w), &i) in pts.iter().zip(&nearest) {
|
||||
let i = i as usize;
|
||||
for a in 0..3 {
|
||||
sum[i][a] += f64::from(p[a]) * f64::from(*w);
|
||||
}
|
||||
@@ -258,6 +372,20 @@ fn lab_to_rgb8(lab: [f32; 3]) -> Rgb8 {
|
||||
photocraft_color::convert::lab_to_srgb(lab).map(to8)
|
||||
}
|
||||
|
||||
/// Histogram colours as CIE Lab points with their counts. Each point depends only on its own
|
||||
/// colour and `collect` keeps the order, so the parallel map gives the same list; the log/pow
|
||||
/// in `srgb_to_lab` made this a visible serial cost with millions of unique colours.
|
||||
fn lab_points(hist: &[(Rgb8, u32)]) -> Vec<([f32; 3], u32)> {
|
||||
let lab = |(c, n): &(Rgb8, u32)| (srgb_to_lab(c.map(|v| f32::from(v) / 255.0)), *n);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
use rayon::prelude::*;
|
||||
hist.par_iter().map(lab).collect()
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
hist.iter().map(lab).collect()
|
||||
}
|
||||
|
||||
/// Builds the palette for `px`. `colors` is the requested count (2..=256) for the computed
|
||||
/// kinds; forced colours are included first and count toward it.
|
||||
pub fn build_palette(px: &[[f32; 4]], kind: PaletteKind, colors: usize, forced: Forced) -> Result<Vec<Rgb8>, String> {
|
||||
@@ -296,7 +424,7 @@ pub fn build_palette(px: &[[f32; 4]], kind: PaletteKind, colors: usize, forced:
|
||||
if hist.len() <= room {
|
||||
hist.iter().map(|h| h.0).collect()
|
||||
} else {
|
||||
let pts: Vec<([f32; 3], u32)> = hist.iter().map(|(c, n)| (srgb_to_lab(c.map(|v| f32::from(v) / 255.0)), *n)).collect();
|
||||
let pts = lab_points(&hist);
|
||||
let mut centers = median_cut(&pts, room);
|
||||
kmeans(&pts, &mut centers, 4);
|
||||
let mut v: Vec<Rgb8> = centers.iter().map(|c| lab_to_rgb8(*c)).collect();
|
||||
@@ -610,6 +738,253 @@ mod tests {
|
||||
(0..w * h).map(|i| [(i % w) as f32 / (w - 1) as f32, (i / w) as f32 / (h - 1) as f32, 0.3, 1.0]).collect()
|
||||
}
|
||||
|
||||
fn to8_reference(v: f32) -> u8 {
|
||||
(v.clamp(0.0, 1.0) * 255.0).round() as u8
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to8_matches_round_on_samples_and_edges() {
|
||||
// Every half-way point and its neighbours, plus a dense sweep of the unit range.
|
||||
for k in 0..=256u32 {
|
||||
let mid = (k as f32 + 0.5) / 255.0;
|
||||
for d in -3i32..=3 {
|
||||
let v = f32::from_bits((mid.to_bits() as i32 + d) as u32);
|
||||
assert_eq!(to8(v), to8_reference(v), "{v:e}");
|
||||
}
|
||||
}
|
||||
for i in 0..200_000u32 {
|
||||
let v = f32::from_bits(i * 20_000);
|
||||
assert_eq!(to8(v), to8_reference(v), "{v:e}");
|
||||
}
|
||||
for v in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -0.0, 0.0, 1.0, -1.0, 2.0, 1e30, -1e30, f32::MIN_POSITIVE, 1e-45] {
|
||||
assert_eq!(to8(v), to8_reference(v), "{v:e}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[ignore = "checks all 2^32 floats, a few seconds in release; run with --release --ignored"]
|
||||
fn to8_matches_round_for_every_float() {
|
||||
use rayon::prelude::*;
|
||||
let bad = (0..=u32::MAX).into_par_iter().filter(|&b| to8(f32::from_bits(b)) != to8_reference(f32::from_bits(b))).count();
|
||||
assert_eq!(bad, 0);
|
||||
}
|
||||
|
||||
/// A repeatable 96x64 image: a gradient with up to 31 levels of integer-hash noise and a
|
||||
/// few see-through pixels.
|
||||
fn golden_image() -> Vec<[f32; 4]> {
|
||||
let (w, h) = (96usize, 64usize);
|
||||
(0..w * h)
|
||||
.map(|i| {
|
||||
let (x, y) = ((i % w) as f32, (i / w) as f32);
|
||||
let noise = |salt: u32| ((((i as u32).wrapping_mul(2_654_435_761).wrapping_add(salt.wrapping_mul(40_503))) >> 13) & 31) as f32 / 255.0;
|
||||
let alpha = if i % 17 == 0 { 0.25 } else { 1.0 };
|
||||
[x / (w - 1) as f32 * 0.8 + noise(1), y / (h - 1) as f32 * 0.7 + noise(2), ((x + y) / (w + h) as f32) * 0.6 + noise(3), alpha]
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Palettes written down from the version before the speed-up (hash-map histogram, every box
|
||||
/// rescanned each split, comparison sort, serial k-means). Any change to how the palettes
|
||||
/// are built has to give these exact colours.
|
||||
#[test]
|
||||
fn palettes_match_the_recorded_originals() {
|
||||
let px = golden_image();
|
||||
#[rustfmt::skip]
|
||||
let cases: Vec<(PaletteKind, usize, Forced, &[Rgb8])> = vec![
|
||||
(
|
||||
PaletteKind::Adaptive,
|
||||
16,
|
||||
Forced::None,
|
||||
&[
|
||||
[40, 173, 80], [196, 175, 150], [92, 173, 103], [92, 81, 71], [195, 83, 118], [39, 82, 49],
|
||||
[142, 82, 95], [195, 37, 101], [143, 36, 79], [40, 128, 64], [196, 129, 133], [92, 127, 87],
|
||||
[91, 36, 56], [38, 35, 33], [144, 128, 111], [144, 174, 127],
|
||||
],
|
||||
),
|
||||
(
|
||||
PaletteKind::Perceptual,
|
||||
16,
|
||||
Forced::None,
|
||||
&[
|
||||
[104, 111, 87], [51, 129, 68], [48, 178, 79], [110, 74, 77], [165, 46, 93], [205, 50, 107],
|
||||
[200, 154, 143], [44, 54, 42], [190, 101, 121], [61, 32, 44], [155, 115, 111], [48, 93, 56],
|
||||
[119, 42, 71], [84, 172, 98], [126, 165, 115], [172, 168, 135],
|
||||
],
|
||||
),
|
||||
(
|
||||
PaletteKind::Selective,
|
||||
24,
|
||||
Forced::BlackWhite,
|
||||
&[
|
||||
[0, 0, 0], [255, 255, 255], [102, 107, 85], [65, 69, 55], [87, 133, 89], [109, 73, 76],
|
||||
[164, 42, 92], [204, 51, 102], [199, 153, 142], [54, 170, 84], [199, 107, 126], [65, 33, 45],
|
||||
[153, 102, 102], [187, 180, 145], [40, 106, 54], [100, 176, 107], [138, 174, 123], [50, 188, 80],
|
||||
[32, 37, 33], [47, 139, 69], [144, 143, 116], [41, 80, 49], [108, 33, 64], [151, 70, 94],
|
||||
],
|
||||
),
|
||||
(
|
||||
PaletteKind::Adaptive,
|
||||
8,
|
||||
Forced::Primaries,
|
||||
&[
|
||||
[0, 0, 0], [255, 255, 255], [255, 0, 0], [0, 255, 0], [0, 0, 255], [0, 255, 255],
|
||||
[255, 0, 255], [255, 255, 0],
|
||||
],
|
||||
),
|
||||
(
|
||||
PaletteKind::Perceptual,
|
||||
12,
|
||||
Forced::BlackWhite,
|
||||
&[
|
||||
[0, 0, 0], [255, 255, 255], [104, 106, 85], [137, 43, 79], [148, 170, 126], [181, 144, 132],
|
||||
[66, 172, 87], [88, 49, 60], [57, 118, 67], [42, 54, 41], [183, 101, 118], [196, 50, 105],
|
||||
],
|
||||
),
|
||||
];
|
||||
for (kind, colors, forced, expected) in cases {
|
||||
assert_eq!(build_palette(&px, kind, colors, forced).unwrap(), expected, "{kind:?} {colors} {forced:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The pre-speed-up median cut and k-means, kept as the oracle for the faster ones.
|
||||
fn median_cut_reference(pts: &[([f32; 3], u32)], n: usize) -> Vec<[f32; 3]> {
|
||||
if pts.is_empty() || n == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut boxes: Vec<Vec<([f32; 3], u32)>> = vec![pts.to_vec()];
|
||||
while boxes.len() < n {
|
||||
let (bi, axis, score) = boxes
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, b)| b.len() > 1)
|
||||
.map(|(i, b)| {
|
||||
let mut best = (0usize, -1.0f32);
|
||||
for a in 0..3 {
|
||||
let (lo, hi) = b.iter().fold((f32::MAX, f32::MIN), |(lo, hi), p| (lo.min(p.0[a]), hi.max(p.0[a])));
|
||||
if hi - lo > best.1 {
|
||||
best = (a, hi - lo);
|
||||
}
|
||||
}
|
||||
let w: f32 = b.iter().map(|p| p.1 as f32).sum();
|
||||
(i, best.0, best.1 * w.sqrt())
|
||||
})
|
||||
.fold((usize::MAX, 0, -1.0f32), |acc, x| if x.2 > acc.2 { x } else { acc });
|
||||
if bi == usize::MAX || score <= 0.0 {
|
||||
break;
|
||||
}
|
||||
let mut b = boxes.swap_remove(bi);
|
||||
b.sort_by(|p, q| p.0[axis].total_cmp(&q.0[axis]));
|
||||
let total: u64 = b.iter().map(|p| u64::from(p.1)).sum();
|
||||
let mut acc = 0u64;
|
||||
let mut cut = 1;
|
||||
for (i, p) in b.iter().enumerate() {
|
||||
acc += u64::from(p.1);
|
||||
if acc * 2 >= total {
|
||||
cut = (i + 1).clamp(1, b.len() - 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
let rest = b.split_off(cut);
|
||||
boxes.push(b);
|
||||
boxes.push(rest);
|
||||
}
|
||||
boxes
|
||||
.iter()
|
||||
.map(|b| {
|
||||
let w: f64 = b.iter().map(|p| f64::from(p.1)).sum();
|
||||
std::array::from_fn(|a| (b.iter().map(|p| f64::from(p.0[a]) * f64::from(p.1)).sum::<f64>() / w) as f32)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn kmeans_reference(pts: &[([f32; 3], u32)], centers: &mut [[f32; 3]], iters: usize) {
|
||||
for _ in 0..iters {
|
||||
let lookup = Nearest::new(centers.iter().copied());
|
||||
let mut sum = vec![[0.0f64; 3]; centers.len()];
|
||||
let mut cnt = vec![0.0f64; centers.len()];
|
||||
for (p, w) in pts {
|
||||
let i = lookup.find(*p);
|
||||
for a in 0..3 {
|
||||
sum[i][a] += f64::from(p[a]) * f64::from(*w);
|
||||
}
|
||||
cnt[i] += f64::from(*w);
|
||||
}
|
||||
for (i, c) in centers.iter_mut().enumerate() {
|
||||
if cnt[i] > 0.0 {
|
||||
*c = std::array::from_fn(|a| (sum[i][a] / cnt[i]) as f32);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Points with many repeated coordinates (ties), negative values, -0.0 and weights up to
|
||||
/// a few thousand, so the stable-sort and float-order rules are all exercised.
|
||||
fn tie_heavy_points(n: usize, seed: u64) -> Vec<([f32; 3], u32)> {
|
||||
let mut s = seed;
|
||||
let mut next = move || {
|
||||
s = s.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1_442_695_040_888_963_407);
|
||||
(s >> 33) as u32
|
||||
};
|
||||
(0..n)
|
||||
.map(|_| {
|
||||
let mut c = |range: u32| match next() % 40 {
|
||||
0 => -0.0,
|
||||
1 => 0.0,
|
||||
_ => (next() % range) as f32 * 0.5 - (range / 4) as f32,
|
||||
};
|
||||
([c(24), c(300), c(150)], 1 + next() % 3000)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn faster_median_cut_and_kmeans_equal_the_originals_bit_for_bit() {
|
||||
// 40 000 points cross the 256-point radix threshold on the first splits.
|
||||
for (n, boxes, seed) in [(40_000, 256, 1), (40_000, 37, 2), (3_000, 256, 3), (200, 64, 4), (2, 8, 5)] {
|
||||
let pts = tie_heavy_points(n, seed);
|
||||
let new = median_cut(&pts, boxes);
|
||||
let old = median_cut_reference(&pts, boxes);
|
||||
assert_eq!(new.len(), old.len());
|
||||
for (a, b) in new.iter().zip(&old) {
|
||||
assert_eq!(a.map(f32::to_bits), b.map(f32::to_bits), "median cut, n={n} boxes={boxes}");
|
||||
}
|
||||
let (mut a, mut b) = (new, old);
|
||||
kmeans(&pts, &mut a, 3);
|
||||
kmeans_reference(&pts, &mut b, 3);
|
||||
for (x, y) in a.iter().zip(&b) {
|
||||
assert_eq!(x.map(f32::to_bits), y.map(f32::to_bits), "kmeans, n={n} boxes={boxes}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn radix_sort_is_the_stable_total_cmp_sort() {
|
||||
for axis in 0..3 {
|
||||
let mut pts = tie_heavy_points(5_000, 9 + axis as u64);
|
||||
// The weight field doubles as an id: equal keys must keep their input order.
|
||||
for (i, p) in pts.iter_mut().enumerate() {
|
||||
p.1 = i as u32;
|
||||
}
|
||||
pts[10].0[axis] = f32::NAN;
|
||||
pts[11].0[axis] = -f32::NAN;
|
||||
pts[12].0[axis] = f32::INFINITY;
|
||||
pts[13].0[axis] = f32::NEG_INFINITY;
|
||||
let mut expected = pts.clone();
|
||||
expected.sort_by(|p, q| p.0[axis].total_cmp(&q.0[axis]));
|
||||
radix_sort_along(&mut pts, axis);
|
||||
let bits = |v: &[([f32; 3], u32)]| v.iter().map(|p| (p.0.map(f32::to_bits), p.1)).collect::<Vec<_>>();
|
||||
assert_eq!(bits(&pts), bits(&expected), "axis {axis}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn histogram_counts_opaque_pixels_sorted() {
|
||||
let px = [[1.0, 0.0, 0.0, 1.0], [0.0, 0.0, 1.0, 1.0], [1.0, 0.0, 0.0, 0.5], [1.0, 0.0, 0.0, 0.49], [0.0, 0.0, 1.0, 1.0], [0.0, 0.0, 0.0, 1.0]];
|
||||
assert_eq!(histogram(&px), vec![([0, 0, 0], 1), ([0, 0, 255], 2), ([255, 0, 0], 2)]);
|
||||
assert!(histogram(&[]).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fixed_palettes_have_expected_sizes() {
|
||||
assert_eq!(web_palette().len(), 216);
|
||||
|
||||
@@ -141,11 +141,15 @@ pub const OBJECT_AT_WORK_PX: usize = 60_000;
|
||||
/// 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
|
||||
/// object's outline. A region covering most of the canvas boundary is treated as background.
|
||||
/// 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.
|
||||
///
|
||||
/// When the whole-canvas pass finds nothing in a large document (an object only a few working
|
||||
/// pixels wide blurs into its surroundings), the search is repeated at finer steps in windows of
|
||||
/// the same working size around the point, down to full resolution.
|
||||
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;
|
||||
@@ -154,7 +158,30 @@ pub fn object_at(sampler: &dyn Sampler, canvas: Rect, x: f32, y: f32, work_px: u
|
||||
if !canvas.contains(px, py) {
|
||||
return None;
|
||||
}
|
||||
let step = super::scale_for(canvas, work_px.max(1024));
|
||||
let work_px = work_px.max(1024);
|
||||
let mut step = super::scale_for(canvas, work_px);
|
||||
if let Some(r) = object_in(sampler, canvas, px, py, step) {
|
||||
return Some(r);
|
||||
}
|
||||
// Side (working pixels) of the square windows of the finer passes.
|
||||
let side = (work_px as f64).sqrt() as usize;
|
||||
while step > 1 {
|
||||
step = (step / 4).max(1);
|
||||
let half = (side.saturating_mul(step) / 2).min(i32::MAX as usize / 4) as i32;
|
||||
let window = Rect::new(px.saturating_sub(half), py.saturating_sub(half), px.saturating_add(half), py.saturating_add(half)).intersect(&canvas);
|
||||
if window.width() < 4 || window.height() < 4 {
|
||||
continue;
|
||||
}
|
||||
if let Some(r) = object_in(sampler, window, px, py, step) {
|
||||
return Some(r);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// One [`object_at`] pass over `canvas` (the document or a window of it) at working step `step`;
|
||||
/// `(px, py)` lies inside `canvas`.
|
||||
fn object_in(sampler: &dyn Sampler, canvas: Rect, px: i32, py: i32, step: usize) -> Option<Region> {
|
||||
let img = sampler.rgb_scaled(canvas, step);
|
||||
let (w, h) = (img.w, img.h);
|
||||
if w < 2 || h < 2 {
|
||||
@@ -166,8 +193,9 @@ pub fn object_at(sampler: &dyn Sampler, canvas: Rect, x: f32, y: f32, work_px: u
|
||||
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 {
|
||||
let boundary = grown.iter().enumerate().filter(|(i, on)| **on && (i % w == 0 || i % w + 1 == w || i / w == 0 || i / w + 1 == h)).count();
|
||||
// shortcut: cropped objects covering most of the frame need a semantic segmenter (#2788).
|
||||
if boundary * 2 >= 2 * w + 2 * h - 4 || count < 4 {
|
||||
return None;
|
||||
}
|
||||
let (mut x0, mut y0, mut x1, mut y1) = (w, h, 0usize, 0usize);
|
||||
|
||||
@@ -751,6 +751,38 @@ fn object_at_finds_the_disc_under_the_cursor() {
|
||||
assert!(v > 0.9, "IoU {v}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_at_keeps_large_foregrounds_but_not_the_surrounding_background() {
|
||||
let (w, h) = (400, 300);
|
||||
for margin in [30, 20, 8] {
|
||||
let inside = |x: usize, y: usize| x >= margin && x < w - margin && y >= margin && y < h - margin;
|
||||
let img = RgbImage::from_fn(w, h, |x, y| {
|
||||
let noise = ((x * 13 + y * 7) % 17) as f32 / 850.0;
|
||||
if inside(x, y) { [0.8 + noise, 0.2, 0.1] } else { [0.1, 0.2, 0.8 + noise] }
|
||||
});
|
||||
let truth: Vec<_> = (0..w * h).map(|i| inside(i % w, i / w)).collect();
|
||||
let sampler = ImageSampler { img: &img, origin: (-40, 25) };
|
||||
let canvas = Rect::new(-40, 25, w as i32 - 40, h as i32 + 25);
|
||||
let region = grabcut::object_at(&sampler, canvas, 160.0, 175.0, grabcut::OBJECT_AT_WORK_PX)
|
||||
.unwrap_or_else(|| panic!("missing large foreground with margin {margin}"));
|
||||
let local = Region { bbox: region.bbox.translate(40, -25), mask: region.mask };
|
||||
assert!(iou(&local, &truth, w, h) > 0.95, "margin {margin}");
|
||||
assert!(grabcut::object_at(&sampler, canvas, -39.0, 26.0, grabcut::OBJECT_AT_WORK_PX).is_none(), "background margin {margin}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_at_allows_a_foreground_touching_one_edge_but_rejects_a_flat_image() {
|
||||
let img = RgbImage::from_fn(400, 300, |x, y| if x < 360 && (20..280).contains(&y) { [0.8, 0.2, 0.1] } else { [0.1, 0.2, 0.8] });
|
||||
let sampler = ImageSampler { img: &img, origin: (0, 0) };
|
||||
let canvas = Rect::new(0, 0, 400, 300);
|
||||
let region = grabcut::object_at(&sampler, canvas, 200.0, 150.0, grabcut::OBJECT_AT_WORK_PX).unwrap();
|
||||
assert!(region.at(0, 150) > 0.9 && region.at(350, 150) > 0.9);
|
||||
assert!(region.at(380, 150) < 0.1 && region.at(200, 5) < 0.1);
|
||||
let flat = RgbImage::from_fn(400, 300, |_, _| [0.5; 3]);
|
||||
assert!(grabcut::object_at(&ImageSampler { img: &flat, origin: (0, 0) }, canvas, 200.0, 150.0, grabcut::OBJECT_AT_WORK_PX).is_none());
|
||||
}
|
||||
|
||||
#[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);
|
||||
@@ -762,6 +794,28 @@ fn object_at_on_the_background_or_off_canvas_finds_nothing() {
|
||||
assert!(grabcut::object_at(&s, canvas, 400.0, 10.0, 0).is_none());
|
||||
}
|
||||
|
||||
/// #6576: a 24 px object in a 4000×3000 document is found (a 1000×800 one still is).
|
||||
#[test]
|
||||
fn object_at_finds_small_object_in_large_canvas() {
|
||||
let (w, h) = (4000usize, 3000usize);
|
||||
let canvas = Rect::new(0, 0, w as i32, h as i32);
|
||||
for (ox, oy, ow, oh) in [(188usize, 138usize, 24usize, 24usize), (1500, 1100, 1000, 800)] {
|
||||
let inside = |x: usize, y: usize| x >= ox && x < ox + ow && y >= oy && y < oy + oh;
|
||||
let img = RgbImage::from_fn(w, h, |x, y| if inside(x, y) { [0.8, 0.1, 0.1] } else { [0.9, 0.9, 0.9] });
|
||||
let truth: Vec<bool> = (0..w * h).map(|i| inside(i % w, i / w)).collect();
|
||||
let s = ImageSampler { img: &img, origin: (0, 0) };
|
||||
let (cx, cy) = ((ox + ow / 2) as f32, (oy + oh / 2) as f32);
|
||||
let reg = grabcut::object_at(&s, canvas, cx, cy, grabcut::OBJECT_AT_WORK_PX);
|
||||
let reg = reg.unwrap_or_else(|| panic!("the {ow}×{oh} object is not found"));
|
||||
let v = iou(®, &truth, w, h);
|
||||
assert!(v > 0.8, "{ow}×{oh}: IoU {v}");
|
||||
}
|
||||
// The flat background around the small object is still nothing.
|
||||
let img = RgbImage::from_fn(w, h, |x, y| if (188..212).contains(&x) && (138..162).contains(&y) { [0.8, 0.1, 0.1] } else { [0.9, 0.9, 0.9] });
|
||||
let s = ImageSampler { img: &img, origin: (0, 0) };
|
||||
assert!(grabcut::object_at(&s, canvas, 400.0, 300.0, grabcut::OBJECT_AT_WORK_PX).is_none());
|
||||
}
|
||||
|
||||
// ---------- Select Subject on the cases reported in #2788 ----------
|
||||
|
||||
/// A figure whose upper part is close to the background colour (a cream shirt on a near-white
|
||||
|
||||
@@ -1,4 +1,28 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
#[ignore = "release Object Finder timing on a 24 MP document"]
|
||||
#[allow(clippy::assertions_on_constants)]
|
||||
fn object_finder_large_foreground_release_timing() {
|
||||
assert!(!cfg!(debug_assertions), "run with --release");
|
||||
let (w, h) = (6000, 4000);
|
||||
let img = RgbImage::from_fn(w, h, |x, y| if (300..5700).contains(&x) && (200..3800).contains(&y) { [0.8, 0.2, 0.1] } else { [0.1, 0.2, 0.8] });
|
||||
let sampler = ImageSampler { img: &img, origin: (0, 0) };
|
||||
let canvas = Rect::new(0, 0, w as i32, h as i32);
|
||||
for (name, x, y) in [("foreground", 3000.0, 2000.0), ("background", 10.0, 10.0)] {
|
||||
let start = std::time::Instant::now();
|
||||
let region = grabcut::object_at(&sampler, canvas, x, y, grabcut::OBJECT_AT_WORK_PX);
|
||||
println!("OBJECT_FINDER {w}x{h} {name}: {:.1} ms", start.elapsed().as_secs_f64() * 1000.0);
|
||||
if name == "foreground" {
|
||||
let region = region.unwrap();
|
||||
assert!(region.at(3000, 2000) > 0.9 && region.at(310, 210) > 0.9);
|
||||
assert!(region.at(10, 10) < 0.1);
|
||||
} else {
|
||||
assert!(region.is_none());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "release comparison of actual Quick Selection strokes on 24-36 MP documents"]
|
||||
#[allow(clippy::assertions_on_constants)]
|
||||
|
||||
@@ -512,8 +512,11 @@ fn warp(
|
||||
any = true;
|
||||
let o = ((y - t.y0) as usize * w + (x - t.x0) as usize) * n;
|
||||
for c in 0..a {
|
||||
// Integer storage clips on write; float colours retain their signed/HDR range.
|
||||
outp[o + c] = (acc[c] / al) as f32;
|
||||
// Un-premultiply by the unclamped alpha: bicubic overshoots it past 1 next to
|
||||
// partly transparent pixels, and dividing by the clamped 1 would lighten the
|
||||
// colour by the overshoot (#6203). Integer storage clips on write; float
|
||||
// colours retain their signed/HDR range.
|
||||
outp[o + c] = (acc[c] / acc[a]) as f32;
|
||||
}
|
||||
outp[o + a] = al as f32;
|
||||
}
|
||||
@@ -837,6 +840,32 @@ mod tests {
|
||||
assert!((p[0] - 0.2).abs() < 0.01 && p[3] > 0.99, "{p:?}");
|
||||
}
|
||||
|
||||
/// #6203: a bicubic rotation of a sprite whose edge pixels are partly transparent overshoots
|
||||
/// its alpha past 1. The colour must stay the sprite's, not grow with the overshoot into a
|
||||
/// light border.
|
||||
#[test]
|
||||
fn rotation_keeps_the_colour_of_a_soft_edged_sprite() {
|
||||
let mut s = rgba();
|
||||
s.fill_rect(Rect::new(10, 10, 30, 30), &[0.8, 0.6, 0.4, 0.5]);
|
||||
s.fill_rect(Rect::new(11, 11, 29, 29), &[0.8, 0.6, 0.4, 1.0]);
|
||||
let (c, k) = (20.0, std::f64::consts::FRAC_1_SQRT_2 * 10.0);
|
||||
let q = [[c, c - 2.0 * k], [c + 2.0 * k, c], [c, c + 2.0 * k], [c - 2.0 * k, c]];
|
||||
let h = Homography::rect_to_quad([10.0, 10.0, 30.0, 30.0], q).unwrap();
|
||||
for interp in [Interp::Bicubic, Interp::Bilinear] {
|
||||
let out = warp_surface(&s, s.content_bounds(), &h, interp).unwrap();
|
||||
let b = out.content_bounds();
|
||||
for y in b.y0..b.y1 {
|
||||
for x in b.x0..b.x1 {
|
||||
let p = out.pixel(x, y);
|
||||
if p[3] > 0.0 {
|
||||
let off = (0..3).map(|i| (p[i] - [0.8, 0.6, 0.4][i]).abs()).fold(0.0f32, f32::max);
|
||||
assert!(off <= 1.0 / 255.0, "{interp:?} ({x}, {y}): {p:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_downscale_is_prefiltered() {
|
||||
// A 1px checkerboard shrunk 8× must come out ~50% grey, not aliased black/white.
|
||||
|
||||
@@ -88,3 +88,162 @@ pub(crate) fn ntsc(src: &Image, out: Rect, ctx: &Ctx) -> Vec<f32> {
|
||||
}
|
||||
res
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{FilterParams, apply};
|
||||
use photocraft_color::{PixelFormat, SampleType};
|
||||
use photocraft_raster::Surface;
|
||||
|
||||
const DEPTHS: [SampleType; 3] = [SampleType::U8, SampleType::U16, SampleType::F32];
|
||||
|
||||
fn tolerance(depth: SampleType) -> f32 {
|
||||
match depth {
|
||||
SampleType::U8 => 1.0 / 255.0,
|
||||
SampleType::U16 => 1.0 / 65535.0,
|
||||
SampleType::F32 => 1e-6,
|
||||
}
|
||||
}
|
||||
|
||||
fn rows(depth: SampleType, bounds: Rect, pixels: &[[f32; 4]]) -> Surface {
|
||||
let mut src = Surface::new(PixelFormat::new(ColorMode::Rgb, depth, true));
|
||||
for (row, px) in pixels.iter().enumerate() {
|
||||
let y = bounds.y0 + row as i32;
|
||||
src.fill_rect(Rect::new(bounds.x0, y, bounds.x1, y + 1), px);
|
||||
}
|
||||
src
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deinterlace_all_field_and_interpolation_choices_use_bounds_relative_rows() {
|
||||
// Odd document origin: parity is relative to the bounds, not absolute y.
|
||||
let b = Rect::new(-2, 11, 0, 16);
|
||||
let pixels = [0.0, 0.2, 0.6, 0.8, 1.0].map(|v| [v, 1.0 - v, v / 2.0, 1.0]);
|
||||
for depth in DEPTHS {
|
||||
let src = rows(depth, b, &pixels);
|
||||
let before = src.read_region(b);
|
||||
for (eliminate_even, interpolate, neighbours) in [
|
||||
(false, false, [(1, 1), (1, 1), (1, 1), (3, 3), (3, 3)]),
|
||||
(false, true, [(1, 1), (1, 1), (1, 3), (3, 3), (3, 3)]),
|
||||
(true, false, [(0, 0), (0, 0), (2, 2), (2, 2), (4, 4)]),
|
||||
(true, true, [(0, 0), (0, 2), (2, 2), (2, 4), (4, 4)]),
|
||||
] {
|
||||
let out = apply(&src, &FilterParams::DeInterlace { eliminate_even, interpolate }, b, b, None);
|
||||
for (row, (a, c)) in neighbours.into_iter().enumerate() {
|
||||
for x in b.x0..b.x1 {
|
||||
let (pa, pc) = (src.pixel(x, b.y0 + a), src.pixel(x, b.y0 + c));
|
||||
let actual = out.pixel(x, b.y0 + row as i32);
|
||||
for channel in 0..4 {
|
||||
let expected = (pa[channel] + pc[channel]) / 2.0;
|
||||
assert!(
|
||||
(actual[channel] - expected).abs() <= tolerance(depth),
|
||||
"{depth:?}, even={eliminate_even}, interp={interpolate}, row={row}: {actual:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(src.read_region(b), before);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deinterlace_interpolation_weights_colour_by_alpha() {
|
||||
let b = Rect::new(0, 0, 1, 3);
|
||||
for depth in DEPTHS {
|
||||
for [a, c] in [[0.25, 0.75], [1.0, 0.0], [0.0, 0.0]] {
|
||||
let src = rows(depth, b, &[[1.0, 0.0, 0.0, a], [0.0, 1.0, 0.0, 1.0], [0.0, 0.0, 1.0, c]]);
|
||||
let out = apply(&src, &FilterParams::DeInterlace { eliminate_even: true, interpolate: true }, b, b, None);
|
||||
let (a, c) = (src.pixel(0, 0)[3], src.pixel(0, 2)[3]);
|
||||
let total = a + c;
|
||||
let expected = if total > 0.0 { [a / total, 0.0, c / total, total / 2.0] } else { [0.0; 4] };
|
||||
let actual = out.pixel(0, 1);
|
||||
for (v, want) in actual.iter().zip(expected) {
|
||||
assert!((v - want).abs() <= tolerance(depth), "{depth:?}: {actual:?} != {expected:?}");
|
||||
}
|
||||
assert_eq!(out.pixel(0, 0), src.pixel(0, 0));
|
||||
assert_eq!(out.pixel(0, 2), src.pixel(0, 2));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deinterlace_one_and_two_row_images_without_alpha_handle_missing_neighbours() {
|
||||
for depth in DEPTHS {
|
||||
for height in [1, 2] {
|
||||
let b = Rect::new(0, -3, 1, -3 + height);
|
||||
let mut src = Surface::new(PixelFormat::new(ColorMode::Grayscale, depth, false));
|
||||
src.fill_rect(b, &[0.2]);
|
||||
if height == 2 {
|
||||
src.fill_rect(Rect::new(0, -2, 1, -1), &[0.8]);
|
||||
}
|
||||
for eliminate_even in [false, true] {
|
||||
for interpolate in [false, true] {
|
||||
let out = apply(&src, &FilterParams::DeInterlace { eliminate_even, interpolate }, b, b, None);
|
||||
let kept_y = if height == 2 && !eliminate_even { -2 } else { -3 };
|
||||
for y in b.y0..b.y1 {
|
||||
assert_eq!(out.pixel(0, y), src.pixel(0, kept_y), "{depth:?}, height={height}, even={eliminate_even}, interp={interpolate}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deinterlace_leaves_pixels_outside_reference_bounds_unchanged() {
|
||||
let area = Rect::new(0, 0, 1, 5);
|
||||
let b = Rect::new(0, 1, 1, 4);
|
||||
let src = rows(SampleType::F32, area, &[[0.1; 4], [0.2; 4], [0.4; 4], [0.8; 4], [1.0; 4]]);
|
||||
for eliminate_even in [false, true] {
|
||||
for interpolate in [false, true] {
|
||||
let out = apply(&src, &FilterParams::DeInterlace { eliminate_even, interpolate }, area, b, None);
|
||||
assert_eq!(out.pixel(0, 0), src.pixel(0, 0));
|
||||
assert_eq!(out.pixel(0, 4), src.pixel(0, 4));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ntsc_safe_colours_and_alpha_are_bit_exact_at_all_depths() {
|
||||
let b = Rect::new(0, 0, 1, 5);
|
||||
for depth in DEPTHS {
|
||||
let src = rows(depth, b, &[[0.0, 0.0, 0.0, 0.0], [0.5, 0.5, 0.5, 0.25], [1.0, 1.0, 1.0, 0.75], [0.2, 0.3, 0.4, 1.0], [1.0, 0.0, 1.0, 1.0]]);
|
||||
let out = apply(&src, &FilterParams::NtscColors, b, b, None);
|
||||
assert_eq!(out.read_region(b), src.read_region(b), "{depth:?}");
|
||||
}
|
||||
}
|
||||
|
||||
fn luma_chroma(p: &[f32]) -> (f32, f32) {
|
||||
let y = 0.299 * p[0] + 0.587 * p[1] + 0.114 * p[2];
|
||||
let i = 0.595716 * p[0] - 0.274453 * p[1] - 0.321263 * p[2];
|
||||
let q = 0.211456 * p[0] - 0.522591 * p[1] + 0.311135 * p[2];
|
||||
(y, i.hypot(q))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ntsc_saturated_colours_keep_luma_alpha_and_meet_composite_limits() {
|
||||
// Independent signal checks: -20..120 IRE relative to the 7.5..100 IRE luma range.
|
||||
let (lo, hi) = ((-20.0 - 7.5) / 92.5, (120.0 - 7.5) / 92.5);
|
||||
let colours = [[1.0, 0.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.25], [1.0, 1.0, 0.0, 0.5], [0.0, 1.0, 1.0, 0.75]];
|
||||
let b = Rect::new(0, 0, 1, colours.len() as i32);
|
||||
for depth in DEPTHS {
|
||||
let src = rows(depth, b, &colours);
|
||||
let out = apply(&src, &FilterParams::NtscColors, b, b, None);
|
||||
// The rounded YIQ inverse contributes < 0.0003; integer writes add one sample step.
|
||||
let eps = 0.0003 + 2.0 * tolerance(depth);
|
||||
for y in b.y0..b.y1 {
|
||||
let (before, after) = (src.pixel(0, y), out.pixel(0, y));
|
||||
let (y0, c0) = luma_chroma(&before);
|
||||
let (y1, c1) = luma_chroma(&after);
|
||||
assert!(y0 - c0 < lo || y0 + c0 > hi, "fixture must require correction");
|
||||
assert!(c1 < c0, "chroma not reduced: {depth:?} {after:?}");
|
||||
assert!((y1 - y0).abs() <= eps, "luma changed: {depth:?} {before:?} -> {after:?}");
|
||||
assert!(y1 - c1 >= lo - eps && y1 + c1 <= hi + eps, "signal out of gamut: {depth:?} {after:?}");
|
||||
assert!(after[..3].iter().all(|v| v.is_finite() && *v >= 0.0 && *v <= 1.0));
|
||||
assert_eq!(after[3], before[3], "alpha changed: {depth:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -431,7 +431,8 @@ fn triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2])], tris
|
||||
any = true;
|
||||
let b = o * n;
|
||||
for c in 0..a {
|
||||
outp[b + c] = (acc[c] / al).clamp(0.0, 1.0) as f32;
|
||||
// The unclamped alpha, so a bicubic overshoot past 1 doesn't lighten the colour (#6203).
|
||||
outp[b + c] = (acc[c] / acc[a]).clamp(0.0, 1.0) as f32;
|
||||
}
|
||||
outp[b + a] = al as f32;
|
||||
}
|
||||
@@ -588,6 +589,28 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rotated_soft_edged_sprite_keeps_its_colour() {
|
||||
// #6203, as in Free Transform: the bicubic alpha overshoots past 1 next to the half
|
||||
// transparent edge, and must not lighten the colour.
|
||||
let mut src = Surface::new(PixelFormat::new(ColorMode::Rgb, SampleType::U8, true));
|
||||
src.fill_rect(Rect::new(10, 10, 30, 30), &[0.8, 0.6, 0.4, 0.5]);
|
||||
src.fill_rect(Rect::new(11, 11, 29, 29), &[0.8, 0.6, 0.4, 1.0]);
|
||||
let k = std::f64::consts::FRAC_1_SQRT_2;
|
||||
let rotate = |x: f64, y: f64| (20.0 + (x - 20.0) * k - (y - 20.0) * k, 20.0 + (x - 20.0) * k + (y - 20.0) * k);
|
||||
let out = warp_mesh_surface(&src, src.content_bounds(), &rotate, Interp::Bicubic);
|
||||
let b = out.content_bounds();
|
||||
for y in b.y0..b.y1 {
|
||||
for x in b.x0..b.x1 {
|
||||
let p = out.pixel(x, y);
|
||||
if p[3] > 0.0 {
|
||||
let off = (0..3).map(|i| (p[i] - [0.8, 0.6, 0.4][i]).abs()).fold(0.0f32, f32::max);
|
||||
assert!(off <= 1.0 / 255.0, "({x}, {y}): {p:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn translation_and_known_arc_points() {
|
||||
let src = sample(SampleType::U8);
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
//! Liquify source reads must depend on the mapping, not the output tile phase.
|
||||
use photocraft_algo::liquify::{LiquifyField, LiquifyStroke, LiquifyTool, apply_liquify};
|
||||
use photocraft_color::{ColorMode, PixelFormat, SampleType};
|
||||
use photocraft_geom::Rect;
|
||||
use photocraft_raster::Surface;
|
||||
|
||||
/// Keeps the document size fixed while moving its edges relative to global tiles.
|
||||
fn bounds(shift: i32) -> Rect {
|
||||
Rect::new(shift, 0, shift + 1024, 512)
|
||||
}
|
||||
|
||||
/// Builds translated copies of RGB ramps and alternating columns so missing
|
||||
/// interpolation taps change the sampled colors.
|
||||
fn source(sample: SampleType, shift: i32) -> Surface {
|
||||
let mut surface = Surface::new(PixelFormat::new(ColorMode::Rgb, sample, false));
|
||||
let mut row = vec![0.0; 1024 * 3];
|
||||
for y in 0..512 {
|
||||
for (x, pixel) in row.as_chunks_mut::<3>().0.iter_mut().enumerate() {
|
||||
*pixel = [x as f32 / 1024.0, (x % 2) as f32, y as f32 / 512.0];
|
||||
}
|
||||
surface.write_region(Rect::new(shift, y, shift + 1024, y + 1), &row);
|
||||
}
|
||||
surface
|
||||
}
|
||||
|
||||
/// Creates matching brush paths in document-relative coordinates, optionally
|
||||
/// beside a frozen region that gives the displacement field a sharp boundary.
|
||||
fn strokes(shift: i32, masked: bool) -> Vec<LiquifyStroke> {
|
||||
let offset = f64::from(shift);
|
||||
let mut strokes = Vec::new();
|
||||
if masked {
|
||||
let mut freeze = LiquifyStroke::new(LiquifyTool::LassoMask, 1.0);
|
||||
freeze.amount = Some(1.0);
|
||||
freeze.points = vec![vec![offset, 0.0], vec![offset + 508.0, 0.0], vec![offset + 508.0, 512.0], vec![offset, 512.0]];
|
||||
strokes.push(freeze);
|
||||
}
|
||||
let mut warp = LiquifyStroke::new(LiquifyTool::ForwardWarp, 512.0);
|
||||
warp.density = 100.0;
|
||||
warp.points = vec![vec![offset + 512.0, 256.5], vec![offset + 499.2, 256.5]];
|
||||
strokes.extend(std::iter::repeat_n(warp, 8));
|
||||
for stroke in &strokes {
|
||||
stroke.validate().expect("the fixture uses supported brush parameters");
|
||||
}
|
||||
strokes
|
||||
}
|
||||
|
||||
/// Checks that a half-tile translation preserves editable output across sample
|
||||
/// depths and mesh sizes when image content and brush paths move together.
|
||||
fn check_tile_translation(masked: bool) {
|
||||
for sample in [SampleType::U8, SampleType::U16, SampleType::F32] {
|
||||
let original = source(sample, 0);
|
||||
let translated = source(sample, 128);
|
||||
for cell in [2.0, 4.0] {
|
||||
let field = LiquifyField::from_strokes(bounds(0), cell, &strokes(0, masked));
|
||||
let shifted_field = LiquifyField::from_strokes(bounds(128), cell, &strokes(128, masked));
|
||||
if masked {
|
||||
assert!(field.d == shifted_field.d, "translation must preserve the nodal displacement values");
|
||||
}
|
||||
// The reported pixels lie entirely on the editable side of the mask.
|
||||
assert_eq!(field.freeze_at(509.5, 220.5), 0.0);
|
||||
assert_eq!(shifted_field.freeze_at(637.5, 220.5), 0.0);
|
||||
let output = apply_liquify(&original, &field);
|
||||
let shifted_output = apply_liquify(&translated, &shifted_field);
|
||||
assert_ne!(output.pixel(509, 220), original.pixel(509, 220), "the fixture must actually warp the inspected strip");
|
||||
let mut worst = 0.0f32;
|
||||
// Translation changes which output tile contains this strip, while
|
||||
// preserving the relative source image, brush path and document edges.
|
||||
for y in 220..290 {
|
||||
for x in 509..512 {
|
||||
let a = output.pixel(x, y);
|
||||
let b = shifted_output.pixel(x + 128, y);
|
||||
for (a, b) in a.iter().zip(&b) {
|
||||
worst = worst.max((a - b).abs());
|
||||
}
|
||||
}
|
||||
}
|
||||
// Coordinate translation can perturb the last few F32 arithmetic bits.
|
||||
// This is far smaller than the clipped source-stencil error.
|
||||
let tolerance = if sample == SampleType::F32 { 2e-6 } else { 0.0 };
|
||||
assert!(worst <= tolerance, "{sample:?}, meshSize {cell}, masked={masked}: moving the document by half a tile changed output by {worst}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cubic_source_footprint_is_independent_of_output_tile_phase() {
|
||||
check_tile_translation(true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordinary_unmasked_warp_is_independent_of_output_tile_phase() {
|
||||
check_tile_translation(false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_reads_still_clamp_at_the_document_edge() {
|
||||
let mut warp = LiquifyStroke::new(LiquifyTool::ForwardWarp, 256.0);
|
||||
warp.density = 100.0;
|
||||
warp.points = vec![vec![1008.0, 256.5], vec![944.0, 256.5]];
|
||||
warp.validate().expect("the edge fixture uses supported brush parameters");
|
||||
let field = LiquifyField::from_strokes(bounds(0), 4.0, &[warp]);
|
||||
assert!(field.sample(1023.5, 256.5)[0] > 4.0, "the fixture must sample beyond the right document edge");
|
||||
for sample in [SampleType::U8, SampleType::U16, SampleType::F32] {
|
||||
let original = source(sample, 0);
|
||||
let output = apply_liquify(&original, &field);
|
||||
assert_eq!(output.pixel(1023, 256), original.pixel(1023, 256), "{sample:?}: an out-of-document source read must repeat the true document edge");
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,6 @@ rust-version.workspace = true
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
photocraft-geom = { workspace = true }
|
||||
photocraft-doc = { workspace = true }
|
||||
photocraft-compose = { workspace = true }
|
||||
photocraft-codecs = { workspace = true }
|
||||
@@ -28,6 +27,8 @@ getrandom = { workspace = true }
|
||||
cap-std = "4"
|
||||
|
||||
[dev-dependencies]
|
||||
# Synthetic camera raws (#7789).
|
||||
photocraft-raw = { workspace = true, features = ["testgen"] }
|
||||
rmcp = { version = "3.5", default-features = false, features = ["client", "server", "macros", "transport-io"] }
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-std", "sync", "net", "io-util", "time"] }
|
||||
|
||||
|
||||
@@ -100,6 +100,8 @@ impl Headless {
|
||||
files::open_bytes(name, &bytes)?
|
||||
}
|
||||
};
|
||||
// Preferences › Raw Defaults › Resolution, as the app's File › Open applies it.
|
||||
file_cmds::raw_defaults(&self.session, &o.warnings, &mut o.document);
|
||||
let path = match file_cmds::template_name(&self.session, requested) {
|
||||
Some(untitled) => {
|
||||
o.document.name = untitled;
|
||||
|
||||
@@ -351,6 +351,19 @@ mod tests {
|
||||
Mutex::new(Headless::trusted_local())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn document_activation_reply_survives_engine_rpc() {
|
||||
let h = session();
|
||||
let mut g = h.lock().unwrap();
|
||||
for _ in 0..2 {
|
||||
g.handle("engine.execute", json!({"command": "file.new", "params": {"width": 4, "height": 4}})).unwrap();
|
||||
}
|
||||
let reply = g.handle("engine.execute", json!({"command": "document.activate", "params": {"offset": -1}})).unwrap();
|
||||
assert_eq!(reply, json!({"document": 0}));
|
||||
let reply = g.handle("engine.execute", json!({"command": "document.activate", "params": {"document": 1}})).unwrap();
|
||||
assert_eq!(reply, json!({"document": 1}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execute_reports_invalid_filter_parameters_without_changing_history() {
|
||||
let h = session();
|
||||
|
||||
@@ -7,7 +7,7 @@ use crate::security::MAX_REQUEST_BYTES;
|
||||
/// Read a complete request before allowing any of its bytes to reach rmcp. `lines()`
|
||||
/// buffers without a limit, while forwarding partial input can make a valid JSON prefix
|
||||
/// executable at EOF after an oversized suffix is rejected.
|
||||
async fn bounded_line<R: tokio::io::AsyncRead + Unpin>(source: &mut BufReader<R>, maximum: usize) -> std::io::Result<Option<String>> {
|
||||
async fn bounded_line<R: tokio::io::AsyncRead + Unpin>(source: &mut BufReader<R>, maximum: usize) -> std::io::Result<Option<Vec<u8>>> {
|
||||
let mut bytes = Vec::new();
|
||||
loop {
|
||||
let available = source.fill_buf().await?;
|
||||
@@ -15,7 +15,7 @@ async fn bounded_line<R: tokio::io::AsyncRead + Unpin>(source: &mut BufReader<R>
|
||||
if bytes.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
return String::from_utf8(bytes).map(Some).map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e));
|
||||
return Ok(Some(bytes));
|
||||
}
|
||||
let newline = available.iter().position(|&byte| byte == b'\n');
|
||||
let length = newline.map_or(available.len(), |at| at + 1);
|
||||
@@ -29,7 +29,7 @@ async fn bounded_line<R: tokio::io::AsyncRead + Unpin>(source: &mut BufReader<R>
|
||||
if bytes.last() == Some(&b'\r') {
|
||||
bytes.pop();
|
||||
}
|
||||
return String::from_utf8(bytes).map(Some).map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e));
|
||||
return Ok(Some(bytes));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -48,11 +48,16 @@ impl PhotocraftMcp {
|
||||
let reader = tokio::spawn(async move {
|
||||
let mut lines = BufReader::new(input);
|
||||
while let Some(line) = bounded_line(&mut lines, MAX_REQUEST_BYTES).await? {
|
||||
if line.trim().is_empty() {
|
||||
if std::str::from_utf8(&line).is_ok_and(|text| text.trim().is_empty()) {
|
||||
continue;
|
||||
}
|
||||
match serde_json::from_str::<Value>(&line) {
|
||||
Ok(_) => to_service.write_all(format!("{line}\n").as_bytes()).await?,
|
||||
// Bytes that are not UTF-8 are malformed JSON too: they get the -32700 reply
|
||||
// like any other bad line instead of ending the session.
|
||||
match serde_json::from_slice::<Value>(&line) {
|
||||
Ok(_) => {
|
||||
to_service.write_all(&line).await?;
|
||||
to_service.write_all(b"\n").await?;
|
||||
}
|
||||
Err(e) => {
|
||||
let reply = json!({"jsonrpc":"2.0", "id":null, "error":{"code":-32700, "message":format!("parse error: {e}")}});
|
||||
if errors.send(reply.to_string()).await.is_err() {
|
||||
@@ -99,8 +104,8 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn bounded_line_accepts_exact_limit_and_final_line() {
|
||||
let mut input = BufReader::new(std::io::Cursor::new(b"1234\nlast!".to_vec()));
|
||||
assert_eq!(bounded_line(&mut input, 5).await.unwrap().as_deref(), Some("1234"));
|
||||
assert_eq!(bounded_line(&mut input, 5).await.unwrap().as_deref(), Some("last!"));
|
||||
assert_eq!(bounded_line(&mut input, 5).await.unwrap().as_deref(), Some(&b"1234"[..]));
|
||||
assert_eq!(bounded_line(&mut input, 5).await.unwrap().as_deref(), Some(&b"last!"[..]));
|
||||
assert!(bounded_line(&mut input, 5).await.unwrap().is_none());
|
||||
|
||||
let mut oversized_eof = BufReader::new(std::io::Cursor::new(b"longer".to_vec()));
|
||||
@@ -143,4 +148,42 @@ mod tests {
|
||||
let documents = observer.headless_op(|h| Ok(h.session.documents().len())).await.unwrap().unwrap();
|
||||
assert_eq!(documents, 0, "the oversized tool call must not run");
|
||||
}
|
||||
|
||||
async fn next_reply(replies: &mut tokio::io::Lines<BufReader<tokio::io::DuplexStream>>) -> Value {
|
||||
let line = tokio::time::timeout(std::time::Duration::from_secs(5), replies.next_line()).await.expect("a reply in time").unwrap().expect("a reply line");
|
||||
serde_json::from_str(&line).unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_line_that_is_not_utf8_gets_a_parse_error_and_the_session_keeps_serving() {
|
||||
let (mut client, input) = tokio::io::duplex(1 << 16);
|
||||
let (output, replies) = tokio::io::duplex(1 << 16);
|
||||
let server = PhotocraftMcp::headless();
|
||||
let observer = server.clone();
|
||||
let session = tokio::spawn(server.serve_io(input, output));
|
||||
let mut replies = BufReader::new(replies).lines();
|
||||
|
||||
let initialize = json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {
|
||||
"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "utf8-test", "version": "1"}
|
||||
}});
|
||||
client.write_all(format!("{initialize}\n").as_bytes()).await.unwrap();
|
||||
assert_eq!(next_reply(&mut replies).await["id"], 1);
|
||||
client.write_all(b"{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\"}\n").await.unwrap();
|
||||
|
||||
client.write_all(b"{\"jsonrpc\":\"2.0\",\"id\":\xff\xfe}\n").await.unwrap();
|
||||
let error = next_reply(&mut replies).await;
|
||||
assert_eq!(error["error"]["code"], -32700, "{error}");
|
||||
assert_eq!(error["id"], Value::Null, "{error}");
|
||||
|
||||
let call = json!({"jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": {"name": "doc_new", "arguments": {"width": 8, "height": 8}}});
|
||||
client.write_all(format!("{call}\n").as_bytes()).await.unwrap();
|
||||
let reply = next_reply(&mut replies).await;
|
||||
assert_eq!(reply["id"], 2, "{reply}");
|
||||
assert_ne!(reply["result"]["isError"], true, "{reply}");
|
||||
let documents = observer.headless_op(|h| Ok(h.session.documents().len())).await.unwrap().unwrap();
|
||||
assert_eq!(documents, 1, "the call after the bad line must run");
|
||||
|
||||
drop(client);
|
||||
session.await.unwrap().unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -383,18 +383,27 @@ fn preferences_may_grant_ambient_paths(id: &str, params: &Value) -> bool {
|
||||
fn preference_uses_ambient_filesystem(path: &str) -> bool {
|
||||
// The engine skips empty segments (`.scriptEvents.enabled` sets `scriptEvents.enabled`), so
|
||||
// judge the first non-empty one; no segment at all is the whole-preferences update.
|
||||
let Some(section) = path.split('.').find(|segment| !segment.is_empty()) else { return true };
|
||||
let mut segments = path.split('.').filter(|segment| !segment.is_empty());
|
||||
let Some(section) = segments.next() else { return true };
|
||||
// File Handling's LUT folder (#6558) is listed from disk; the rest of that section is not.
|
||||
matches!(section, "colorSettings" | "scriptEvents" | "historyLog" | "plugIns" | "scratchDisks")
|
||||
|| (section == "fileHandling" && segments.next().is_none_or(|key| key == "lutFolder"))
|
||||
}
|
||||
|
||||
fn command_uses_ambient_path(id: &str, params: &Value) -> bool {
|
||||
match id {
|
||||
"actions.load" | "brush.presets.importAbr" | "brush.presets.importImage" | "gradient.presets.importGrd" | "plugin.install" | "swatches.import" => {
|
||||
"actions.load"
|
||||
| "brush.presets.importAbr"
|
||||
| "brush.presets.importImage"
|
||||
| "gradient.presets.importGrd"
|
||||
| "plugin.install"
|
||||
| "swatches.import"
|
||||
| "layer.layerStyle.contours.load" => {
|
||||
// `data` wins over `path` in these commands; any `path` without it reads the filesystem.
|
||||
params.get("data").is_none() && params.get("path").is_some()
|
||||
}
|
||||
// With a `path` the file is written there; without one the bytes come back as `data`.
|
||||
"actions.save" | "swatches.export" => params.get("path").is_some(),
|
||||
"actions.save" | "swatches.export" | "layer.layerStyle.contours.save" => params.get("path").is_some(),
|
||||
// Shortcut sets: a .kys read or written by path, or a Summarize page written by path.
|
||||
"edit.keyboardShortcuts" => {
|
||||
params.get("path").is_some() || ["importKys", "exportKys", "summarize"].iter().any(|k| params.get(*k).and_then(|v| v.get("path")).is_some())
|
||||
@@ -836,6 +845,8 @@ mod tests {
|
||||
("swatches.export", serde_json::json!({"path": "/outside/set.ase"})),
|
||||
("actions.load", serde_json::json!({"path": "/outside/set.atn"})),
|
||||
("actions.save", serde_json::json!({"path": "/outside/set.atn"})),
|
||||
("layer.layerStyle.contours.load", serde_json::json!({"path": "/outside/set.shc"})),
|
||||
("layer.layerStyle.contours.save", serde_json::json!({"path": "/outside/set.shc"})),
|
||||
] {
|
||||
assert!(authorize_engine_command(id, ¶ms).is_err(), "{id}: {params}");
|
||||
}
|
||||
@@ -848,6 +859,8 @@ mod tests {
|
||||
("swatches.export", serde_json::json!({"format": "ase"})),
|
||||
("actions.load", serde_json::json!({"data": "QUJD"})),
|
||||
("actions.save", serde_json::json!({"set": "S"})),
|
||||
("layer.layerStyle.contours.load", serde_json::json!({"data": "QUJD"})),
|
||||
("layer.layerStyle.contours.save", serde_json::json!({"names": ["a"]})),
|
||||
] {
|
||||
assert!(authorize_engine_command(id, ¶ms).is_ok(), "{id}: {params}");
|
||||
}
|
||||
@@ -862,6 +875,8 @@ mod tests {
|
||||
"historyLog.filePath",
|
||||
".historyLog.filePath",
|
||||
"plugIns.additionalPluginsFolder",
|
||||
"fileHandling.lutFolder",
|
||||
"fileHandling",
|
||||
"scratchDisks.disks",
|
||||
".",
|
||||
"..historyLog.filePath",
|
||||
@@ -872,6 +887,7 @@ mod tests {
|
||||
authorize_engine_command("prefs.set", &serde_json::json!({"values": {"interface.language": "fr", "historyLog.filePath": "/outside/log"}})).is_err()
|
||||
);
|
||||
assert!(authorize_engine_command("prefs.set", &serde_json::json!({"path": "interface.language", "value": "fr"})).is_ok());
|
||||
assert!(authorize_engine_command("prefs.set", &serde_json::json!({"path": "fileHandling.autosave", "value": false})).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
//! M11 acceptance, part two: ten more scripted edit tasks (21–30) over MCP (roadmap M11 DoD).
|
||||
//!
|
||||
//! Same contract as `agent_tasks.rs`: each task uses only MCP tools (`doc_*`, `command_run`) the
|
||||
//! way an agent would, and checks the outcome through `doc_inspect`, `document.pixel` or the
|
||||
//! exported files, never engine internals.
|
||||
|
||||
use photocraft_automation::{AuthorizedWorkspace, PhotocraftMcp};
|
||||
use rmcp::model::{CallToolRequestParams, CallToolResult, ClientConfig};
|
||||
use rmcp::service::RunningService;
|
||||
use rmcp::{ClientHandler, RoleClient, ServiceExt};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct Client;
|
||||
impl ClientHandler for Client {
|
||||
fn get_info(&self) -> ClientConfig {
|
||||
ClientConfig::default()
|
||||
}
|
||||
}
|
||||
|
||||
type Conn = RunningService<RoleClient, Client>;
|
||||
type Server = tokio::task::JoinHandle<()>;
|
||||
|
||||
async fn connect(root: &std::path::Path) -> (Conn, Server) {
|
||||
let (s, c) = tokio::io::duplex(1 << 20);
|
||||
let workspace = AuthorizedWorkspace::new(Some(root), Some(root)).expect("test workspace");
|
||||
let server = tokio::spawn(async move {
|
||||
if let Ok(running) = PhotocraftMcp::headless_with_workspace(workspace).serve(s).await {
|
||||
let _ = running.waiting().await;
|
||||
}
|
||||
});
|
||||
(Client.serve(c).await.expect("client init"), server)
|
||||
}
|
||||
|
||||
async fn shutdown(c: Conn, server: Server) {
|
||||
c.cancel().await.unwrap();
|
||||
server.await.unwrap();
|
||||
}
|
||||
|
||||
fn text(r: &CallToolResult) -> String {
|
||||
r.content.iter().filter_map(|c| c.as_text()).map(|t| t.text.clone()).collect::<Vec<_>>().join("\n")
|
||||
}
|
||||
|
||||
/// Call a tool and parse its JSON result, failing the test on a tool error.
|
||||
async fn tool(c: &Conn, name: &str, args: Value) -> Value {
|
||||
let mut p = CallToolRequestParams::new(name.to_owned());
|
||||
if let Value::Object(m) = args {
|
||||
p = p.with_arguments(m);
|
||||
}
|
||||
let r = c.call_tool(p).await.expect("call_tool transport");
|
||||
assert_ne!(r.is_error, Some(true), "{name} failed: {}", text(&r));
|
||||
serde_json::from_str(&text(&r)).unwrap_or(Value::String(text(&r)))
|
||||
}
|
||||
|
||||
async fn run(c: &Conn, id: &str, params: Value) -> Value {
|
||||
tool(c, "command_run", json!({"id": id, "params": params})).await
|
||||
}
|
||||
|
||||
async fn inspect(c: &Conn) -> Value {
|
||||
tool(c, "doc_inspect", json!({})).await
|
||||
}
|
||||
|
||||
async fn pixel(c: &Conn, x: i32, y: i32) -> Vec<f64> {
|
||||
let v = run(c, "document.pixel", json!({"x": x, "y": y})).await;
|
||||
let arr = if v.is_array() { v } else { v.get("result").cloned().unwrap_or(v) };
|
||||
arr.as_array().unwrap_or_else(|| panic!("pixel: {arr}")).iter().map(|x| x.as_f64().unwrap()).collect()
|
||||
}
|
||||
|
||||
fn top_layer(doc: &Value) -> &Value {
|
||||
&doc["layers"][0]
|
||||
}
|
||||
|
||||
fn tmp(name: &str) -> std::path::PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("pc-agent-{}-{name}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
fn close(a: &[f64], b: &[f64], tol: f64) -> bool {
|
||||
a.iter().zip(b).all(|(x, y)| (x - y).abs() <= tol)
|
||||
}
|
||||
|
||||
/// The next ten acceptance tasks (21–30): fill layers, adjustment layers with masks, grouping,
|
||||
/// merging, gradients of edits, channel round trips, JPEG export and a native save round trip
|
||||
/// that preserves a group.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn agent_completes_ten_extended_tasks() {
|
||||
let dir = tmp("tasks3");
|
||||
let (c, server) = connect(&dir).await;
|
||||
|
||||
// 21. Paint a red background and cover it with a solid-colour fill layer.
|
||||
tool(&c, "doc_new", json!({"width": 64, "height": 64, "background": "white"})).await;
|
||||
run(&c, "select.all", json!({})).await;
|
||||
run(&c, "edit.fill", json!({"color": "#ff0000"})).await;
|
||||
run(&c, "select.deselect", json!({})).await;
|
||||
run(&c, "layer.newFillLayer.solidColor", json!({"color": "#00ffff"})).await;
|
||||
assert_eq!(top_layer(&inspect(&c).await)["kind"], "Fill", "task 21");
|
||||
let cyan = pixel(&c, 32, 32).await;
|
||||
assert!(cyan[1] > 0.99 && cyan[2] > 0.99 && cyan[0] < 0.01, "task 21: the fill covers red {cyan:?}");
|
||||
|
||||
// 22. An Invert adjustment flips it to red; deleting the adjustment restores cyan.
|
||||
run(&c, "layer.newAdjustmentLayer.invert", json!({})).await;
|
||||
assert_eq!(top_layer(&inspect(&c).await)["kind"], "Adjustment", "task 22");
|
||||
let inverted = pixel(&c, 32, 32).await;
|
||||
assert!(inverted[0] > 0.99 && inverted[1] < 0.01 && inverted[2] < 0.01, "task 22: cyan inverts to red {inverted:?}");
|
||||
run(&c, "layer.delete", json!({})).await;
|
||||
assert!(close(&pixel(&c, 32, 32).await, &cyan, 1e-4), "task 22: deleting the adjustment restores cyan");
|
||||
|
||||
// 23. Hide a rectangular selection behind the layer's mask.
|
||||
run(&c, "layer.delete", json!({})).await; // drop the fill so the red background shows
|
||||
run(&c, "select.rect", json!({"x": 16, "y": 16, "width": 32, "height": 32})).await;
|
||||
run(&c, "layer.layerMask.hideSelection", json!({})).await;
|
||||
let doc = inspect(&c).await;
|
||||
assert_eq!(top_layer(&doc)["hasMask"], true, "task 23");
|
||||
let hidden = pixel(&c, 32, 32).await;
|
||||
let kept = pixel(&c, 4, 4).await;
|
||||
assert!(hidden[3] < 0.01, "task 23: the selection is masked away {hidden:?}");
|
||||
assert!(kept[3] > 0.99 && kept[0] > 0.99, "task 23: outside keeps the red {kept:?}");
|
||||
run(&c, "layer.layerMask.delete", json!({})).await;
|
||||
|
||||
// 24. Group every layer; the pixels are unchanged.
|
||||
let before = pixel(&c, 32, 32).await;
|
||||
run(&c, "select.allLayers", json!({})).await;
|
||||
run(&c, "layer.groupLayers", json!({})).await;
|
||||
let doc = inspect(&c).await;
|
||||
assert_eq!(top_layer(&doc)["kind"], "Group", "task 24: {doc}");
|
||||
assert!(close(&pixel(&c, 32, 32).await, &before, 1e-4), "task 24: grouping is visual only");
|
||||
|
||||
// 25. Flatten the grouped document to one layer.
|
||||
run(&c, "layer.flattenImage", json!({})).await;
|
||||
assert_eq!(inspect(&c).await["layers"].as_array().unwrap().len(), 1, "task 25");
|
||||
|
||||
// 26. Duplicate the layer and delete the copy.
|
||||
run(&c, "layer.duplicate", json!({})).await;
|
||||
assert_eq!(inspect(&c).await["layers"].as_array().unwrap().len(), 2, "task 26");
|
||||
run(&c, "layer.delete", json!({})).await;
|
||||
assert_eq!(inspect(&c).await["layers"].as_array().unwrap().len(), 1, "task 26");
|
||||
|
||||
// 27. A Curves adjustment darkens the red; deleting it restores the original.
|
||||
run(&c, "layer.newAdjustmentLayer.curves", json!({"points": [[0, 0], [128, 32], [255, 128]]})).await;
|
||||
let dark = pixel(&c, 32, 32).await;
|
||||
assert!(dark[0] < 0.9, "task 27: the red channel deepens {dark:?}");
|
||||
run(&c, "layer.delete", json!({})).await;
|
||||
assert!(close(&pixel(&c, 32, 32).await, &before, 1e-4), "task 27: deleting restores the red");
|
||||
|
||||
// 28. Hue/Saturation at -100 desaturates the red to gray.
|
||||
run(&c, "layer.newAdjustmentLayer.hueSaturation", json!({"saturation": -100})).await;
|
||||
let gray = pixel(&c, 32, 32).await;
|
||||
assert!((gray[0] - gray[1]).abs() < 0.05 && (gray[1] - gray[2]).abs() < 0.05, "task 28: saturated red goes gray {gray:?}");
|
||||
run(&c, "layer.delete", json!({})).await;
|
||||
|
||||
// 29. Save a selection to a channel, load it back and paint through it.
|
||||
run(&c, "select.rect", json!({"x": 20, "y": 20, "width": 24, "height": 24})).await;
|
||||
run(&c, "select.saveSelection", json!({"name": "Box"})).await;
|
||||
run(&c, "select.deselect", json!({})).await;
|
||||
run(&c, "select.loadSelection", json!({"channel": "Box"})).await;
|
||||
run(&c, "edit.fill", json!({"color": "#0000ff"})).await;
|
||||
let painted = pixel(&c, 32, 32).await;
|
||||
let outside = pixel(&c, 4, 4).await;
|
||||
assert!(painted[2] > 0.99 && painted[0] < 0.01, "task 29: the loaded channel fills blue {painted:?}");
|
||||
assert!(outside[0] > 0.99 && outside[2] < 0.01, "task 29: outside keeps red {outside:?}");
|
||||
run(&c, "select.deselect", json!({})).await;
|
||||
|
||||
// 30. Save natively with a group, reopen and find the group and its content intact.
|
||||
run(&c, "layer.new.layer", json!({"name": "Badge"})).await;
|
||||
run(&c, "select.rect", json!({"x": 40, "y": 40, "width": 16, "height": 16})).await;
|
||||
run(&c, "edit.fill", json!({"color": "#00ff00"})).await;
|
||||
run(&c, "select.deselect", json!({})).await;
|
||||
run(&c, "select.allLayers", json!({})).await;
|
||||
run(&c, "layer.groupLayers", json!({"name": "Set"})).await;
|
||||
tool(&c, "doc_save", json!({"path": "grouped.pcraft"})).await;
|
||||
tool(&c, "doc_close", json!({})).await;
|
||||
tool(&c, "doc_open", json!({"path": "grouped.pcraft"})).await;
|
||||
let doc = inspect(&c).await;
|
||||
assert_eq!(top_layer(&doc)["kind"], "Group", "task 30: {doc}");
|
||||
assert_eq!(top_layer(&doc)["name"], "Set", "task 30: {doc}");
|
||||
let green = pixel(&c, 48, 48).await;
|
||||
assert!(green[1] > 0.99 && green[0] < 0.01, "task 30: the badge inside the group survives {green:?}");
|
||||
tool(&c, "doc_close", json!({})).await;
|
||||
|
||||
shutdown(c, server).await;
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
@@ -774,11 +774,12 @@ async fn embedded_smart_objects_can_be_edited_saved_and_unpacked_without_file_au
|
||||
let client = connect(PhotocraftMcp::headless()).await;
|
||||
let run = async |id: &str, params: Value| json_of(&call(&client, "command_run", json!({"id": id, "params": params})).await);
|
||||
json_of(&call(&client, "doc_new", json!({"width": 16, "height": 12, "background": "white"})).await);
|
||||
run("layer.smartObjects.convertToSmartObject", json!({})).await;
|
||||
let outer = run("layer.smartObjects.convertToSmartObject", json!({})).await["layer"].clone();
|
||||
// An explicit target works even with a regular pixel layer selected.
|
||||
run("layer.new.layer", json!({"name": "Other layer"})).await;
|
||||
assert_eq!(run("layer.smartObjects.editContents", json!({"layer": outer})).await["document"], 1);
|
||||
// A smart object cannot be converted again, so nest one inside the contents instead.
|
||||
run("layer.smartObjects.convertToSmartObject", json!({})).await;
|
||||
assert_eq!(run("layer.smartObjects.editContents", json!({})).await["document"], 2);
|
||||
run("layer.setProps", json!({"name": "Edited photo", "opacity": 0.5})).await;
|
||||
assert_eq!(run("layer.smartObjects.saveContents", json!({})).await["updated"], true);
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
//! #7789: the headless (MCP) `doc.open` opens a developed raw at Preferences › Raw Defaults ›
|
||||
//! Resolution, as the app's File › Open does.
|
||||
|
||||
use photocraft_automation::Headless;
|
||||
use photocraft_raw::testgen::{DngSpec, mosaic, scene};
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn doc_open_gives_a_raw_the_raw_defaults_resolution() {
|
||||
let dir = std::env::temp_dir().join(format!("pc-raw-defaults-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let (w, h) = (24, 16);
|
||||
let path = dir.join("IMG_0001.dng");
|
||||
std::fs::write(&path, DngSpec::cfa(w, h, mosaic(&scene(w, h), w, [0, 1, 1, 2], 0, 65535)).build()).unwrap();
|
||||
let mut headless = Headless::trusted_local();
|
||||
headless.handle("engine.execute", json!({"command": "prefs.set", "params": {"path": "rawDefaults.resolution", "value": 240}})).unwrap();
|
||||
headless.handle("doc.open", json!({"path": path.to_str().unwrap()})).unwrap();
|
||||
assert_eq!(headless.session.active().unwrap().doc.resolution_dpi, 240.0);
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
@@ -172,11 +172,16 @@ impl Clut {
|
||||
}
|
||||
let o = self.outputs;
|
||||
out[..o].iter_mut().for_each(|v| *v = 0.0);
|
||||
for corner in 0..(1usize << n) {
|
||||
// Only dimensions with more than one grid point contribute a cube axis. Degenerate
|
||||
// dimensions have step 0 and ratio 0, so they always multiply the corner weight by 1
|
||||
// and never change the offset; enumerating 2^n corners over all declared inputs would
|
||||
// do 2^(degenerate) redundant iterations. Interpolate over the effective dims only.
|
||||
let eff: Vec<usize> = (0..n).filter(|&d| s[d] != 0).collect();
|
||||
for corner in 0..(1usize << eff.len()) {
|
||||
let mut w = 1.0f32;
|
||||
let mut off = base;
|
||||
for d in 0..n {
|
||||
if corner & (1 << d) != 0 {
|
||||
for (bit, &d) in eff.iter().enumerate() {
|
||||
if corner & (1 << bit) != 0 {
|
||||
w *= r[d];
|
||||
off += s[d];
|
||||
} else {
|
||||
@@ -245,4 +250,20 @@ mod tests {
|
||||
c.eval(&[0.5, 0.25], &mut o);
|
||||
assert!((o[0] - 0.75).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn degenerate_dimensions_do_not_change_the_result() {
|
||||
// Dimensions with a single grid point contribute nothing, but the old code still
|
||||
// enumerated 2^inputs corners over them. Interpolating over effective dims only must
|
||||
// give the same answer as a reference built without the degenerate axes.
|
||||
let f = |p: &[f32], o: &mut [f32]| o[0] = 0.25 * p[0] + 0.5 * p[2] + 0.125 * p[4];
|
||||
// Axes 1 and 3 have a single grid point (degenerate); axes 0, 2, 4 vary.
|
||||
let c = Clut::sample(vec![3, 1, 4, 1, 5], 1, f);
|
||||
let mut got = [0.0];
|
||||
for p in [[0.2f32, 0.0, 0.7, 0.0, 0.4], [1.0, 0.0, 0.0, 0.0, 1.0], [0.5, 0.0, 0.5, 0.0, 0.5]] {
|
||||
c.eval_multilinear(&p, &mut got);
|
||||
let want = 0.25 * p[0] + 0.5 * p[2] + 0.125 * p[4];
|
||||
assert!((got[0] - want).abs() < 1e-5, "got {} want {want}", got[0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,6 +99,20 @@ impl Curve {
|
||||
(0..n).map(|i| self.eval64(i as f64 / (n - 1) as f64) as f32).collect()
|
||||
}
|
||||
|
||||
/// Inverse of the curve at each `y` in `ys`. For sampled-table curves the flat-run edge
|
||||
/// indices are computed once for the whole batch instead of rescanning the table on every
|
||||
/// call, so inverting a long constant table many times stays linear.
|
||||
pub fn inverse_table(&self, ys: impl Iterator<Item = f64>) -> Vec<f32> {
|
||||
match self {
|
||||
Curve::Table(t) if t.len() >= 2 => {
|
||||
let increasing = t[t.len() - 1] >= t[0];
|
||||
let (edge_start, edge_end) = (edge_index(t, true), edge_index(t, false));
|
||||
ys.map(|y| table_inverse_with(t, y as f32, increasing, edge_start, edge_end)).collect()
|
||||
}
|
||||
_ => ys.map(|y| self.eval_inverse64(y) as f32).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `true` when the curve never decreases over `[0, 1]`.
|
||||
pub fn is_monotonic(&self) -> bool {
|
||||
let s = self.sample(1024);
|
||||
@@ -244,13 +258,24 @@ fn table_inverse(t: &[f32], y: f32) -> f32 {
|
||||
return y;
|
||||
}
|
||||
let increasing = t[n - 1] >= t[0];
|
||||
table_inverse_with(t, y, increasing, edge_index(t, true), edge_index(t, false))
|
||||
}
|
||||
|
||||
/// [`table_inverse`] with the flat-run edge indices supplied. Computing those edges scans the
|
||||
/// whole table, and transform construction inverts the same table thousands of times, so the
|
||||
/// edges are computed once and reused across a batch (see [`Curve::inverse_table`]).
|
||||
fn table_inverse_with(t: &[f32], y: f32, increasing: bool, edge_start: f32, edge_end: f32) -> f32 {
|
||||
let n = t.len();
|
||||
if n < 2 {
|
||||
return y;
|
||||
}
|
||||
let (lo_v, hi_v) = if increasing { (t[0], t[n - 1]) } else { (t[n - 1], t[0]) };
|
||||
if y <= lo_v {
|
||||
// Flat start: choose the last index still at the lowest value.
|
||||
return if increasing { edge_index(t, true) } else { edge_index(t, false) };
|
||||
return if increasing { edge_start } else { edge_end };
|
||||
}
|
||||
if y >= hi_v {
|
||||
return if increasing { edge_index(t, false) } else { edge_index(t, true) };
|
||||
return if increasing { edge_end } else { edge_start };
|
||||
}
|
||||
let (mut lo, mut hi) = (0usize, n - 1);
|
||||
while hi - lo > 1 {
|
||||
@@ -336,4 +361,24 @@ mod tests {
|
||||
let g = Curve::Gamma(2.2);
|
||||
assert!((g.eval64(-0.5) + 0.5f64.powf(2.2)).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inverse_table_matches_per_call_including_flat_runs() {
|
||||
// Batch inverse must equal calling eval_inverse64 per point. Include a table with flat
|
||||
// runs at both ends (the case that used to rescan the table on every call).
|
||||
let tables = [
|
||||
Curve::Table(vec![0.0, 0.0, 0.0, 0.25, 0.5, 1.0, 1.0, 1.0]),
|
||||
Curve::Table(vec![0.0, 0.2, 0.4, 0.6, 0.8, 1.0]),
|
||||
Curve::Table(vec![1.0, 0.5, 0.0]),
|
||||
Curve::Gamma(2.2),
|
||||
];
|
||||
let ys: Vec<f64> = (0..=40).map(|i| i as f64 / 40.0).collect();
|
||||
for c in &tables {
|
||||
let batch = c.inverse_table(ys.iter().copied());
|
||||
for (i, &y) in ys.iter().enumerate() {
|
||||
let one = c.eval_inverse64(y) as f32;
|
||||
assert!((batch[i] - one).abs() < 1e-6, "y={y}: batch {} vs per-call {one}", batch[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,7 +107,14 @@ pub fn parse_cube(text: &str) -> Result<LutFile, LutError> {
|
||||
let (mut dmin, mut dmax) = ([0.0f32; 3], [1.0f32; 3]);
|
||||
let mut rows: Vec<f32> = Vec::new();
|
||||
for line in text.lines() {
|
||||
let line = line.split('#').next().unwrap_or("").trim();
|
||||
// A '#' inside a quoted TITLE is part of the title, not a comment.
|
||||
let raw = line.trim_start();
|
||||
let skip = if raw.starts_with("TITLE") { raw.find('"').and_then(|q| raw[q + 1..].find('"').map(|e| q + e + 2)) } else { None }.unwrap_or(0);
|
||||
let line = match raw[skip..].find('#') {
|
||||
Some(i) => &raw[..skip + i],
|
||||
None => raw,
|
||||
}
|
||||
.trim();
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
@@ -230,6 +237,10 @@ pub fn write_cube(l: &LutFile) -> String {
|
||||
s.push_str(&format!("TITLE \"{}\"\n", l.title.replace('"', "'")));
|
||||
}
|
||||
s.push_str(&format!("LUT_3D_SIZE {}\n", l.size));
|
||||
if l.domain_min != [0.0; 3] || l.domain_max != [1.0; 3] {
|
||||
s.push_str(&format!("DOMAIN_MIN {} {} {}\n", l.domain_min[0], l.domain_min[1], l.domain_min[2]));
|
||||
s.push_str(&format!("DOMAIN_MAX {} {} {}\n", l.domain_max[0], l.domain_max[1], l.domain_max[2]));
|
||||
}
|
||||
for c in l.data.as_chunks::<3>().0 {
|
||||
s.push_str(&format!("{:.6} {:.6} {:.6}\n", c[0], c[1], c[2]));
|
||||
}
|
||||
@@ -301,6 +312,32 @@ pub fn builtin(id: &str) -> Option<LutFile> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn cube_title_with_hash_round_trips() {
|
||||
let l = LutFile::from_fn("Look #3", 2, |c| c);
|
||||
assert_eq!(parse_cube(&write_cube(&l)).unwrap().title, "Look #3");
|
||||
let t = "TITLE \"A #b\" # note\nLUT_3D_SIZE 2 # c\n# comment\n";
|
||||
let rows = "0 0 0 # x\n1 0 0\n0 1 0\n1 1 0\n0 0 1\n1 0 1\n0 1 1\n1 1 1\n";
|
||||
let p = parse_cube(&format!("{t}{rows}")).unwrap();
|
||||
assert_eq!(p.title, "A #b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_cube_keeps_non_default_domain() {
|
||||
let mut l = LutFile::identity(2);
|
||||
l.domain_min = [0.1; 3];
|
||||
l.domain_max = [0.9; 3];
|
||||
let back = parse_cube(&write_cube(&l)).unwrap();
|
||||
assert_eq!(back.domain_min, [0.1; 3]);
|
||||
assert_eq!(back.domain_max, [0.9; 3]);
|
||||
l.domain_min = [0.0, 0.0, 0.5];
|
||||
l.domain_max = [1.0; 3];
|
||||
let back = parse_cube(&write_cube(&l)).unwrap();
|
||||
assert_eq!(back.domain_min, [0.0, 0.0, 0.5]);
|
||||
assert_eq!(back.domain_max, [1.0; 3]);
|
||||
assert!(!write_cube(&LutFile::identity(2)).contains("DOMAIN"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cube_domain_is_kept_and_validated() {
|
||||
let base = "TITLE \"t\"
|
||||
|
||||
@@ -133,12 +133,11 @@ impl Transform {
|
||||
let tables = cs
|
||||
.iter()
|
||||
.map(|c| {
|
||||
(0..OUT_TABLE)
|
||||
.map(|i| {
|
||||
let u = i as f64 / (OUT_TABLE - 1) as f64;
|
||||
c.eval_inverse64(u * u * u * u).clamp(0.0, 1.0) as f32
|
||||
})
|
||||
.collect()
|
||||
let ys = (0..OUT_TABLE).map(|i| {
|
||||
let u = i as f64 / (OUT_TABLE - 1) as f64;
|
||||
u * u * u * u
|
||||
});
|
||||
c.inverse_table(ys).into_iter().map(|v| v.clamp(0.0, 1.0)).collect()
|
||||
})
|
||||
.collect();
|
||||
post = Some(Post { tables });
|
||||
|
||||
@@ -47,7 +47,9 @@ let out = encode(&img, Format::Tiff, &EncodeOptions::default())?;
|
||||
| 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 |
|
||||
| Cineon | yes | **no** | U16 (10-bit code values) | Gray, RGB | no | no | no | no | no | no | n/a | built in |
|
||||
| Cineon | yes | yes | U16 (10-bit code values read and written exactly through U16) | Gray, RGB | no | no | no | no | no | no | no | built in |
|
||||
| DICOM | yes | **no** | U8, U16 | Gray, RGB | no | no | no | no | no | no | n/a | built in |
|
||||
| Amiga IFF (ILBM/PBM) | yes | **no** | U8 | RGB, RGBA | yes | no | no | no | no | no | n/a | 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` |
|
||||
|
||||
@@ -73,8 +75,9 @@ embedded table (and a PNG's transparent entry) so `photocraft-io` can open them
|
||||
|
||||
* **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
|
||||
with the legacy header or the DX10 one. Uncompressed row padding follows the header's
|
||||
`DDSD_PITCH` stride; without that flag, rows are tightly packed. 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 (`EncodeOptions::dds`) makes
|
||||
a 2D texture with the legacy header: uncompressed by default (BGRA, BGRX, or luminance with or
|
||||
without alpha, so it reads back exactly), or BC1/BC2/BC3. The block encoder is our own, from
|
||||
@@ -106,13 +109,34 @@ embedded table (and a PNG's transparent entry) so `photocraft-io` can open them
|
||||
Other descriptors, signed or run-length-encoded data and other bit depths return
|
||||
`CodecError::Unsupported` naming the field.
|
||||
|
||||
* **Cineon (read-only).** Kodak's 10-bit logarithmic film-scan format (the one DPX grew from),
|
||||
* **Cineon.** Kodak's 10-bit logarithmic film-scan format (the one DPX grew from),
|
||||
read from the same Kodak header family: the generic 1024-byte header, big-endian, one (Gray)
|
||||
or three (RGB) channels, three 10-bit samples packed into each 32-bit word, lines on a word
|
||||
boundary. The 10-bit code values open as U16 `round(c × 65535 / 1023)`; the file's black/white
|
||||
reference points are a log-conversion choice and are not applied. A depth other than 10 bits or
|
||||
a channel count other than 1/3 returns `CodecError::Unsupported`; truncation errors rather than
|
||||
panicking. There is no Cineon writer (`ASYMMETRIC_EXCEPTIONS`); save as DPX, TIFF or EXR.
|
||||
panicking. Writing follows the Cineon 4.5 layout: a 1024-byte generic header (version `V4.5`,
|
||||
one or three channels designated as universal-metric gray or R, G, B, 10 bits, data 0–1023 over
|
||||
densities 0–2.048) and a 1024-byte film header left undefined, then the samples three to a
|
||||
big-endian 32-bit word, left justified (packing 5), lines on a word boundary. Samples are written
|
||||
as `round(v × 1023 / 65535)`, so a frame opened from Cineon keeps its code values; any other
|
||||
image takes the encode plan (alpha dropped, CMYK and Lab to RGB) and is quantized to 10 bits.
|
||||
|
||||
* **DICOM (read-only).** Single-frame medical images over the uncompressed transfer syntaxes
|
||||
(implicit VR little-endian, explicit VR little/big-endian): MONOCHROME1/2 gray (with the
|
||||
1 = white inversion) and RGB/YBR_FULL, 8- and 16-bit allocated samples, interleaved or planar,
|
||||
scaled from `BitsStored`/`HighBit` and mapped up when `PixelRepresentation` is signed.
|
||||
Undefined-length sequences outside the pixel data are skipped, as clinical files contain them.
|
||||
Compressed transfer syntaxes, sequences over the pixel data and other photometric
|
||||
interpretations return `Unsupported`/`Malformed`. There is no DICOM writer
|
||||
(`ASYMMETRIC_EXCEPTIONS`).
|
||||
|
||||
* **Amiga IFF (read-only).** `FORM ... ILBM` stores interleaved 1–8 bitplanes;
|
||||
`FORM ... PBM ` stores 8-bit chunky palette indices with even-padded rows. Both support
|
||||
stored and ByteRun1 compression, a `CMAP` palette (grey ramp when absent, black for missing
|
||||
entries), and transparent-colour masking. ILBM also supports a separate mask plane. HAM/EHB
|
||||
viewport modes, PBM mask planes, other bit depths and compressions return
|
||||
`CodecError::Unsupported`. There is no IFF writer; save as PNG.
|
||||
|
||||
* **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
|
||||
@@ -147,7 +171,10 @@ embedded table (and a PNG's transparent entry) so `photocraft-io` can open them
|
||||
with `decode_tiff_page` (and `photocraft_io::import_tiff_page`). Every frame of a GIF comes
|
||||
from `decode_gif_frames`: each frame composited onto the logical screen with the disposal
|
||||
methods applied, as RGBA8 with its delay in milliseconds, within `Limits` and
|
||||
`MAX_GIF_FRAMES`. The video-layer commands use it (`photocraft_io::import_gif_frames`).
|
||||
`MAX_GIF_FRAMES`. Every frame of an animated WebP comes from `decode_webp_frames` the same
|
||||
way, composited onto its canvas, with its duration in milliseconds and the file's ICC profile.
|
||||
The video-layer commands use both (`photocraft_io::import_gif_frames`,
|
||||
`photocraft_io::import_webp_frames`).
|
||||
* **EXIF in TIFF** is stored as a sub-IFD rather than a blob, so it is not preserved yet.
|
||||
`caps.exif = false` for TIFF, and a warning is raised.
|
||||
* **Orientation** (EXIF tag 274 in JPEG, PNG `eXIf` and WebP; the decoded page's tag in TIFF and
|
||||
|
||||
@@ -69,3 +69,10 @@ path = "fuzz_targets/decode_gif.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
||||
[[bin]]
|
||||
name = "decode_webp"
|
||||
path = "fuzz_targets/decode_webp.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
#![no_main]
|
||||
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
use photocraft_codecs::{decode_as_with, decode_webp_frames, DecodeOptions, Format, Limits};
|
||||
|
||||
fuzz_target!(|data: &[u8]| {
|
||||
let opts = DecodeOptions {
|
||||
limits: Limits { max_width: 4096, max_height: 4096, max_pixels: 1 << 22, max_alloc: 256 << 20 },
|
||||
..Default::default()
|
||||
};
|
||||
let _ = decode_as_with(Format::WebP, data, &opts);
|
||||
let _ = decode_webp_frames(data, &opts);
|
||||
});
|
||||
@@ -117,15 +117,29 @@ impl Encoding {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,
|
||||
/// Stored row stride and byte size of the top mip level (block rows for BC).
|
||||
fn level_layout(&self, width: u32, height: u32, pitch: Option<u32>) -> Result<(usize, usize), CodecError> {
|
||||
let overflow = || bad("pixel data size overflow");
|
||||
let (w, h) = (usize::try_from(width).map_err(|_| overflow())?, usize::try_from(height).map_err(|_| overflow())?);
|
||||
let (row_bytes, rows) = match self {
|
||||
Encoding::Masks { bytes, .. } => {
|
||||
let packed = w.checked_mul(*bytes).ok_or_else(overflow)?;
|
||||
let row_bytes = match pitch {
|
||||
Some(pitch) => usize::try_from(pitch).map_err(|_| overflow())?,
|
||||
None => packed,
|
||||
};
|
||||
if row_bytes < packed {
|
||||
return Err(bad(format!("row pitch {row_bytes} is smaller than the {packed}-byte pixel row")));
|
||||
}
|
||||
(row_bytes, h)
|
||||
}
|
||||
Encoding::Bc1 | Encoding::Bc2 | Encoding::Bc3 => {
|
||||
let block = if matches!(self, Encoding::Bc1) { 8 } else { 16 };
|
||||
(w.div_ceil(4).checked_mul(block).ok_or_else(overflow)?, h.div_ceil(4))
|
||||
}
|
||||
};
|
||||
w.div_ceil(4).checked_mul(h.div_ceil(4))?.checked_mul(block)
|
||||
let size = row_bytes.checked_mul(rows).ok_or_else(overflow)?;
|
||||
Ok((row_bytes, size))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,19 +209,25 @@ pub(crate) fn decode(b: &[u8], limits: &Limits) -> Result<Image, CodecError> {
|
||||
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"))?;
|
||||
// The same header field is a total level size for BC, but a row stride when DDSD_PITCH is set.
|
||||
let pitch = if flags & DDSD_PITCH != 0 { Some(le32(b, 20)?) } else { None };
|
||||
let (row_pitch, size) = encoding.level_layout(width, height, pitch)?;
|
||||
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 row_size = w.checked_mul(channels).ok_or_else(|| bad("pixel size overflow"))?;
|
||||
let total = row_size.checked_mul(h).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]);
|
||||
for (src_row, dst_row) in data.chunks_exact(row_pitch).zip(pixels.chunks_exact_mut(row_size)) {
|
||||
// Stop at the output width, leaving any stored row padding unread.
|
||||
for (src, dst) in src_row.chunks_exact(bytes).zip(dst_row.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 => {
|
||||
|
||||
@@ -341,15 +341,17 @@ pub(crate) fn decode_deep(meta: &MetaData, bytes: &[u8], limits: &Limits) -> Res
|
||||
return Err(err(format!("chunk declares part {part}, expected {header_index}")));
|
||||
}
|
||||
}
|
||||
// Block coordinates → the block's global pixel range and its slot.
|
||||
let (block_pixels, slot, row_w): (Vec<usize>, usize, usize) = match &header.blocks {
|
||||
// Block coordinates → the block's pixel bounds and its slot. The per-pixel index is
|
||||
// built only after the chunk's declared payload sizes are validated below, so a
|
||||
// truncated or lying chunk cannot drive a large index allocation first.
|
||||
let (block_rows, block_cols, slot, row_w, scanline_row): (std::ops::Range<usize>, std::ops::Range<usize>, usize, usize, bool) = match &header.blocks {
|
||||
BlockDescription::ScanLines => {
|
||||
let y = i32(&mut cur)?;
|
||||
let row = match y.checked_sub(y0) {
|
||||
Some(d) if d >= 0 && (d as usize) < h => d as usize,
|
||||
_ => return Err(err(format!("scan line block y={y} is outside the data window"))),
|
||||
};
|
||||
((0..w).map(|x| row * w + x).collect(), row, w)
|
||||
(row..row + 1, 0..w, row, w, true)
|
||||
}
|
||||
BlockDescription::Tiles(_) => {
|
||||
let (tx, ty, lx, ly) = (i32(&mut cur)?, i32(&mut cur)?, i32(&mut cur)?, i32(&mut cur)?);
|
||||
@@ -362,16 +364,11 @@ pub(crate) fn decode_deep(meta: &MetaData, bytes: &[u8], limits: &Limits) -> Res
|
||||
let (bx, by) = ((tx as usize) * tile_w, (ty as usize) * tile_h);
|
||||
let rows = (by + tile_h).min(h);
|
||||
let cols = (bx + tile_w).min(w);
|
||||
let mut px = Vec::with_capacity((rows - by) * (cols - bx));
|
||||
for yy in by..rows {
|
||||
for xx in bx..cols {
|
||||
px.push(yy.saturating_mul(w).saturating_add(xx));
|
||||
}
|
||||
}
|
||||
let slot = (ty as usize).saturating_mul(tiles_x) + tx as usize;
|
||||
(px, slot, cols - bx)
|
||||
(by..rows, bx..cols, slot, cols - bx, false)
|
||||
}
|
||||
};
|
||||
let block_len = block_rows.len().saturating_mul(block_cols.len());
|
||||
if seen.get(slot).copied().unwrap_or(true) {
|
||||
return Err(err(format!("two chunks cover block {slot}")));
|
||||
}
|
||||
@@ -386,6 +383,20 @@ pub(crate) fn decode_deep(meta: &MetaData, bytes: &[u8], limits: &Limits) -> Res
|
||||
if packed_table > remaining || packed_data > remaining.saturating_sub(packed_table) {
|
||||
return Err(err("deep chunk sizes point past the end of the file"));
|
||||
}
|
||||
// The chunk's payload is present; now materialize the block's global pixel index. For
|
||||
// scan lines this is one row; for tiles the clipped tile rectangle.
|
||||
let block_pixels: Vec<usize> = if scanline_row {
|
||||
let row = block_rows.start;
|
||||
(0..w).map(|x| row * w + x).collect()
|
||||
} else {
|
||||
let mut px = Vec::with_capacity(block_len);
|
||||
for yy in block_rows.clone() {
|
||||
for xx in block_cols.clone() {
|
||||
px.push(yy.saturating_mul(w).saturating_add(xx));
|
||||
}
|
||||
}
|
||||
px
|
||||
};
|
||||
let what = "the sample-count table";
|
||||
let table_bytes = read_exact(&mut cur, packed_table as usize, what)?;
|
||||
let expected_table = block_pixels.len().checked_mul(4).ok_or_else(|| err("count table overflows"))?;
|
||||
@@ -552,7 +563,7 @@ fn header_end(bytes: &[u8]) -> Result<u64, CodecError> {
|
||||
/// into straight alpha at the end. Without any alpha channel the front-most sample wins,
|
||||
/// opaque. Z, ZBack and other deep channels influence the flat result only through the
|
||||
/// ordering; volume samples (with ZBack) are approximated as point samples.
|
||||
pub(crate) fn flatten(deep: &DeepImage) -> Result<Image, CodecError> {
|
||||
pub(crate) fn flatten(deep: &DeepImage, limits: &Limits) -> Result<Image, CodecError> {
|
||||
let npx = deep.width as usize * deep.height as usize;
|
||||
let has = |n: &str| deep.channel(n).is_some();
|
||||
let rgb = has("R") && has("G") && has("B");
|
||||
@@ -569,6 +580,10 @@ pub(crate) fn flatten(deep: &DeepImage) -> Result<Image, CodecError> {
|
||||
return Err(CodecError::unsupported(F, "no R/G/B or Y channels in the deep part"));
|
||||
};
|
||||
let nc = layout.channels();
|
||||
// Admit the flattened output at its true size: nc f32 samples per pixel. The deep decode
|
||||
// admitted the data window at four bytes per pixel, before the flattened channel layout
|
||||
// was known, so re-check here with the actual layout and f32 sample size.
|
||||
limits.check_bytes(deep.width, deep.height, nc as u64 * 4)?;
|
||||
let samples = |n: &str| -> &[f32] { deep.channel(n).map(|c| c.samples.as_slice()).unwrap_or(&[]) };
|
||||
let (r, g, b) = (samples("R"), samples("G"), samples("B"));
|
||||
let (ar, ag, ab) = (samples("AR"), samples("AG"), samples("AB"));
|
||||
@@ -665,6 +680,10 @@ pub(crate) fn flatten(deep: &DeepImage) -> Result<Image, CodecError> {
|
||||
// pads to the stage at its offset, transparent elsewhere (limits validated the size).
|
||||
let mut img = match deep.stage {
|
||||
Some((sw, sh)) => {
|
||||
// The display stage can be larger than the data window; admit it at the real f32
|
||||
// layout size before allocating, since the earlier deep-decode check used four
|
||||
// bytes per pixel and did not know the flattened channel count.
|
||||
limits.check_bytes(sw, sh, nc as u64 * 4)?;
|
||||
let mut padded = vec![0f32; sw as usize * sh as usize * nc];
|
||||
let row_len = deep.width as usize * nc;
|
||||
let dx = deep.origin.0.max(0) as usize;
|
||||
|
||||
@@ -0,0 +1,517 @@
|
||||
//! DICOM (medical imaging) reader, uncompressed transfer syntaxes only.
|
||||
//!
|
||||
//! Supports what a viewer needs from a single-frame image: MONOCHROME1/2 (gray) and RGB or
|
||||
//! YBR_FULL, 8- and 16-bit samples, interleaved or planar, both byte orders and explicit or
|
||||
//! implicit VR. Undefined-length sequences outside the pixel data are skipped (clinical files
|
||||
//! contain them); compressed transfer syntaxes, encapsulated pixel data and other photometric
|
||||
//! interpretations are refused with a clear error. Read-only in this crate.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::error::CodecError;
|
||||
use crate::format::Format;
|
||||
use crate::image::{ChannelLayout, Image, SampleType};
|
||||
use crate::options::Limits;
|
||||
|
||||
const F: Format = Format::Dicom;
|
||||
const PREAMBLE: usize = 132; // 128 zero bytes + "DICM"
|
||||
const UNDEFINED: u32 = 0xFFFF_FFFF;
|
||||
|
||||
fn bad(msg: impl std::fmt::Display) -> CodecError {
|
||||
CodecError::malformed(F, msg)
|
||||
}
|
||||
|
||||
/// A DICOM file begins with a 128-byte preamble and the `DICM` magic.
|
||||
pub(crate) fn is_dicom(b: &[u8]) -> bool {
|
||||
b.get(128..132) == Some(b"DICM")
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct Endian {
|
||||
big: bool,
|
||||
}
|
||||
|
||||
/// The transfer syntax: byte order and whether value representations are explicit.
|
||||
#[derive(Clone, Copy)]
|
||||
struct Syntax {
|
||||
endian: Endian,
|
||||
explicit: bool,
|
||||
}
|
||||
|
||||
/// The value representations whose length field is 2 reserved bytes + `u32` in explicit VR.
|
||||
fn long_vr(vr: &[u8]) -> bool {
|
||||
matches!(vr, b"OB" | b"OW" | b"OF" | b"SQ" | b"UT" | b"UN" | b"OD" | b"OL" | b"OV" | b"UC" | b"UR")
|
||||
}
|
||||
|
||||
/// A cursor over the byte stream.
|
||||
struct Cur<'a> {
|
||||
b: &'a [u8],
|
||||
pos: usize,
|
||||
big: bool,
|
||||
}
|
||||
|
||||
impl<'a> Cur<'a> {
|
||||
fn take(&mut self, n: usize) -> Option<&'a [u8]> {
|
||||
let s = self.b.get(self.pos..self.pos.checked_add(n)?)?;
|
||||
self.pos += n;
|
||||
Some(s)
|
||||
}
|
||||
fn u16(&mut self) -> Option<u16> {
|
||||
let c = self.take(2)?;
|
||||
Some(if self.big { u16::from_be_bytes([c[0], c[1]]) } else { u16::from_le_bytes([c[0], c[1]]) })
|
||||
}
|
||||
fn u32(&mut self) -> Option<u32> {
|
||||
let c = self.take(4)?;
|
||||
Some(if self.big { u32::from_be_bytes([c[0], c[1], c[2], c[3]]) } else { u32::from_le_bytes([c[0], c[1], c[2], c[3]]) })
|
||||
}
|
||||
/// Skip `len` bytes, refusing to run past the buffer.
|
||||
fn skip(&mut self, len: usize) -> Result<(), CodecError> {
|
||||
self.pos = self.pos.checked_add(len).filter(|p| *p <= self.b.len()).ok_or_else(|| bad("a value runs past the end of the file"))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Read one element's tag and value length, leaving the cursor at the value. Item and delimiter
|
||||
/// tags (group `FFFE`) carry a `u32` length with no VR even under explicit VR.
|
||||
fn read_header(c: &mut Cur, syn: &Syntax) -> Result<(u16, u16, [u8; 2], u32), CodecError> {
|
||||
let group = c.u16().ok_or_else(|| bad("truncated element tag"))?;
|
||||
let elem = c.u16().ok_or_else(|| bad("truncated element tag"))?;
|
||||
let (vr, len) = if group == 0xFFFE {
|
||||
([0, 0], c.u32().ok_or_else(|| bad("truncated item length"))?)
|
||||
} else if syn.explicit {
|
||||
let vr = c.take(2).ok_or_else(|| bad("truncated VR"))?;
|
||||
let v = [vr[0], vr[1]];
|
||||
let len = if long_vr(&v) {
|
||||
c.take(2).ok_or_else(|| bad("truncated VR"))?; // reserved
|
||||
c.u32().ok_or_else(|| bad("truncated length"))?
|
||||
} else {
|
||||
u32::from(c.u16().ok_or_else(|| bad("truncated length"))?)
|
||||
};
|
||||
(v, len)
|
||||
} else {
|
||||
([0, 0], c.u32().ok_or_else(|| bad("truncated length"))?)
|
||||
};
|
||||
Ok((group, elem, vr, len))
|
||||
}
|
||||
|
||||
/// Skip an undefined-length sequence, ending just after its `FFFE,E0DD` delimiter. Nesting is
|
||||
/// tracked through undefined-length items and nested sequences; everything else is skipped by
|
||||
/// its length. Returns the position after the delimiter.
|
||||
fn skip_undefined(c: &mut Cur, syn: &Syntax) -> Result<(), CodecError> {
|
||||
let mut depth = 1u32;
|
||||
while depth > 0 {
|
||||
let (group, elem, _, len) = read_header(c, syn)?;
|
||||
// Item (E00D) and sequence (E0DD) delimiters close one undefined length; they carry no
|
||||
// content of their own.
|
||||
if group == 0xFFFE && matches!(elem, 0xE00D | 0xE0DD) {
|
||||
depth -= 1;
|
||||
continue;
|
||||
}
|
||||
if len == UNDEFINED {
|
||||
depth += 1;
|
||||
} else {
|
||||
c.skip(len as usize)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Walk the elements from `start`, calling `f` with each `(group, element, value)`. An
|
||||
/// undefined-length sequence outside the pixel data is skipped; encapsulated (undefined-length)
|
||||
/// pixel data is refused. Returns the number of bytes walked.
|
||||
fn elements(b: &[u8], start: usize, syn: &Syntax, mut f: impl FnMut(u16, u16, &Elem) -> Result<(), CodecError>) -> Result<usize, CodecError> {
|
||||
let mut c = Cur { b, pos: start, big: syn.endian.big };
|
||||
loop {
|
||||
if c.pos >= b.len() {
|
||||
return Ok(c.pos - start);
|
||||
}
|
||||
let (group, elem, vr, len) = read_header(&mut c, syn)?;
|
||||
if len == UNDEFINED {
|
||||
if (group, elem) == (0x7FE0, 0x0010) {
|
||||
return Err(CodecError::unsupported(F, "encapsulated (compressed) pixel data"));
|
||||
}
|
||||
skip_undefined(&mut c, syn)?;
|
||||
continue;
|
||||
}
|
||||
let len = len as usize;
|
||||
let start = c.pos;
|
||||
if b.len() < start.saturating_add(len) {
|
||||
return Err(bad("an element value runs past the end of the file"));
|
||||
}
|
||||
f(group, elem, &Elem { vr, start, len })?;
|
||||
c.pos = start + len;
|
||||
}
|
||||
}
|
||||
|
||||
/// A parsed element: its VR and the byte range of its value.
|
||||
struct Elem {
|
||||
vr: [u8; 2],
|
||||
start: usize,
|
||||
len: usize,
|
||||
}
|
||||
|
||||
fn u16_val(b: &[u8], e: &Elem, big: bool) -> Option<u16> {
|
||||
let c = b.get(e.start..e.start.checked_add(2)?)?;
|
||||
Some(if big { u16::from_be_bytes([c[0], c[1]]) } else { u16::from_le_bytes([c[0], c[1]]) })
|
||||
}
|
||||
|
||||
/// The transfer syntax (byte order and explicit/implicit VR) and the data-set start. The file
|
||||
/// meta group is always explicit VR little-endian; the data set follows its `(0002,0010)`
|
||||
/// TransferSyntaxUID.
|
||||
fn transfer_syntax(b: &[u8]) -> Result<(Syntax, usize), CodecError> {
|
||||
let meta = Syntax { endian: Endian { big: false }, explicit: true };
|
||||
let mut ts: Option<String> = None;
|
||||
let mut data_start = b.len();
|
||||
let mut c = Cur { b, pos: PREAMBLE, big: false };
|
||||
while c.pos + 8 <= b.len() {
|
||||
let save = c.pos;
|
||||
let (group, elem, _, len) = read_header(&mut c, &meta)?;
|
||||
if group != 0x0002 {
|
||||
// A non-meta group begins the data set.
|
||||
data_start = save;
|
||||
break;
|
||||
}
|
||||
if len == UNDEFINED {
|
||||
return Err(CodecError::unsupported(F, "undefined-length meta element"));
|
||||
}
|
||||
let start = c.pos;
|
||||
if b.len() < start.saturating_add(len as usize) {
|
||||
return Err(bad("a meta element runs past the end of the file"));
|
||||
}
|
||||
if elem == 0x0010 {
|
||||
ts = Some(String::from_utf8_lossy(&b[start..start + len as usize]).trim_end_matches(['\0', ' ']).to_string());
|
||||
}
|
||||
c.pos = start + len as usize;
|
||||
}
|
||||
let syn = match ts.as_deref() {
|
||||
Some("1.2.840.10008.1.2") => Syntax { endian: Endian { big: false }, explicit: false },
|
||||
Some("1.2.840.10008.1.2.1") => Syntax { endian: Endian { big: false }, explicit: true },
|
||||
Some("1.2.840.10008.1.2.2") => Syntax { endian: Endian { big: true }, explicit: true },
|
||||
Some(other) => return Err(CodecError::unsupported(F, format!("transfer syntax {other} (only uncompressed is read)"))),
|
||||
// No meta: the historical default is implicit VR little-endian.
|
||||
None => Syntax { endian: Endian { big: false }, explicit: false },
|
||||
};
|
||||
Ok((syn, data_start))
|
||||
}
|
||||
|
||||
pub(crate) fn decode(b: &[u8], limits: &Limits) -> Result<Image, CodecError> {
|
||||
if !is_dicom(b) {
|
||||
return Err(bad("not a DICOM file (no DICM magic)"));
|
||||
}
|
||||
let (syn, start) = transfer_syntax(b)?;
|
||||
let mut tags: HashMap<(u16, u16), Elem> = HashMap::new();
|
||||
elements(b, start, &syn, |g, e, el| {
|
||||
// Later duplicates must not silently override the first (DICOM forbids duplicates).
|
||||
tags.entry((g, e)).or_insert_with(|| Elem { vr: el.vr, start: el.start, len: el.len });
|
||||
Ok(())
|
||||
})?;
|
||||
let big = syn.endian.big;
|
||||
let get_u16 = |tag: (u16, u16)| tags.get(&tag).and_then(|e| u16_val(b, e, big));
|
||||
let rows = u32::from(get_u16((0x0028, 0x0010)).ok_or_else(|| bad("missing Rows (0028,0010)"))?);
|
||||
let cols = u32::from(get_u16((0x0028, 0x0011)).ok_or_else(|| bad("missing Columns (0028,0011)"))?);
|
||||
let samples = u32::from(get_u16((0x0028, 0x0002)).unwrap_or(1));
|
||||
let bits = get_u16((0x0028, 0x0100)).ok_or_else(|| bad("missing BitsAllocated (0028,0100)"))?;
|
||||
let bits_stored = get_u16((0x0028, 0x0101)).unwrap_or(bits);
|
||||
let high_bit = get_u16((0x0028, 0x0102)).unwrap_or(bits_stored.saturating_sub(1));
|
||||
if u32::from(high_bit) >= u32::from(bits) {
|
||||
return Err(bad("HighBit is outside the allocated bits"));
|
||||
}
|
||||
let signed = get_u16((0x0028, 0x0103)).unwrap_or(0) == 1;
|
||||
let planar = get_u16((0x0028, 0x0006)).unwrap_or(0);
|
||||
let photometric = tags
|
||||
.get(&(0x0028, 0x0004))
|
||||
.and_then(|e| b.get(e.start..e.start.checked_add(e.len)?))
|
||||
.map(|v| String::from_utf8_lossy(v).trim_end_matches(['\0', ' ']).to_string())
|
||||
.ok_or_else(|| bad("missing PhotometricInterpretation (0028,0004)"))?;
|
||||
let pixel = tags.get(&(0x7FE0, 0x0010)).ok_or_else(|| CodecError::unsupported(F, "no pixel data (7FE0,0010)"))?;
|
||||
if !matches!(bits, 8 | 16) || !matches!(bits_stored, 1..=16) {
|
||||
return Err(CodecError::unsupported(F, format!("{bits}-bit allocated / {bits_stored}-bit stored samples")));
|
||||
}
|
||||
let mono = matches!(photometric.as_str(), "MONOCHROME1" | "MONOCHROME2");
|
||||
let rgb = photometric == "RGB";
|
||||
let ybr = photometric == "YBR_FULL";
|
||||
if !(mono || rgb || ybr) {
|
||||
return Err(CodecError::unsupported(F, format!("photometric interpretation {photometric}")));
|
||||
}
|
||||
// Mono stores one sample; RGB and YBR_FULL store three. Any other count would index past the
|
||||
// pixel data, so it is refused rather than misread.
|
||||
let want_samples = if mono { 1 } else { 3 };
|
||||
if samples != want_samples {
|
||||
return Err(CodecError::unsupported(F, format!("{samples} samples per pixel with {photometric}")));
|
||||
}
|
||||
if ybr && bits != 8 {
|
||||
return Err(CodecError::unsupported(F, format!("{bits}-bit {photometric} (8-bit only)")));
|
||||
}
|
||||
let layout = if mono { ChannelLayout::Gray } else { ChannelLayout::Rgb };
|
||||
let out_sample = if bits == 8 { SampleType::U8 } else { SampleType::U16 };
|
||||
limits.check(cols, rows, layout, out_sample)?;
|
||||
|
||||
let pixels = (cols as usize).checked_mul(rows as usize).ok_or_else(|| CodecError::LimitExceeded("image too large".into()))?;
|
||||
let n = pixels.checked_mul(samples as usize).ok_or_else(|| CodecError::LimitExceeded("image too large".into()))?;
|
||||
let bytes_per = if bits == 16 { 2usize } else { 1 };
|
||||
let need = n.checked_mul(bytes_per).ok_or_else(|| CodecError::LimitExceeded("pixel data too large".into()))?;
|
||||
let data = b
|
||||
.get(pixel.start..pixel.start.checked_add(need).ok_or_else(|| CodecError::LimitExceeded("pixel data too large".into()))?)
|
||||
.ok_or_else(|| bad("pixel data is truncated"))?;
|
||||
let out_channels = layout.channels();
|
||||
let out_len = pixels.checked_mul(out_channels).and_then(|k| k.checked_mul(bytes_per)).ok_or_else(|| CodecError::LimitExceeded("image too large".into()))?;
|
||||
let mut out = Vec::new();
|
||||
out.try_reserve_exact(out_len).map_err(|_| CodecError::LimitExceeded("cannot allocate the image".into()))?;
|
||||
// Raw sample of channel `ch` at pixel `p`, honouring the planar configuration.
|
||||
let sample_at = |p: usize, ch: usize| -> Option<u32> {
|
||||
let idx = if planar == 1 { ch.checked_mul(pixels)?.checked_add(p)? } else { p.checked_mul(samples as usize)?.checked_add(ch)? };
|
||||
let off = idx.checked_mul(bytes_per)?;
|
||||
if bits == 16 {
|
||||
let c = data.get(off..off.checked_add(2)?)?;
|
||||
Some(if big { u16::from_be_bytes([c[0], c[1]]) } else { u16::from_le_bytes([c[0], c[1]]) }.into())
|
||||
} else {
|
||||
Some(u32::from(*data.get(off)?))
|
||||
}
|
||||
};
|
||||
let stored_max = f64::from((1u32 << bits_stored) - 1);
|
||||
// Extract the `bits_stored` samples (the least significant one at `high_bit`) and scale to
|
||||
// [0, 1]; signed values are shifted up by half a range first. `high_bit` is 1-based here.
|
||||
let shift = (u32::from(high_bit) + 1).saturating_sub(u32::from(bits_stored));
|
||||
let mask = (1u32 << bits_stored) - 1;
|
||||
let half = 1u32 << (bits_stored - 1);
|
||||
let norm = move |raw: u32| -> f64 {
|
||||
let v = (raw >> shift) & mask;
|
||||
// Signed: flip the sign bit to map [-2^(b-1), 2^(b-1)-1] onto [0, 2^b-1].
|
||||
let v = if signed { v ^ half } else { v };
|
||||
(f64::from(v) / stored_max).clamp(0.0, 1.0)
|
||||
};
|
||||
let q = |x: f64| -> u16 { (x * 65535.0).round() as u16 };
|
||||
let push = |out: &mut Vec<u8>, x: f64| {
|
||||
if bits == 8 {
|
||||
out.push((x * 255.0).round() as u8);
|
||||
} else {
|
||||
out.extend_from_slice(&q(x).to_ne_bytes());
|
||||
}
|
||||
};
|
||||
for p in 0..pixels {
|
||||
let s = |ch: usize| sample_at(p, ch).ok_or_else(|| bad("pixel data is shorter than the image"));
|
||||
if mono {
|
||||
let x = norm(s(0)?);
|
||||
push(&mut out, if photometric == "MONOCHROME1" { 1.0 - x } else { x });
|
||||
} else if rgb {
|
||||
for ch in 0..3 {
|
||||
push(&mut out, norm(s(ch)?));
|
||||
}
|
||||
} else {
|
||||
// YBR_FULL (8-bit): BT.601 full-range to RGB.
|
||||
let y = f64::from(s(0)?);
|
||||
let cb = f64::from(s(1)?) - 128.0;
|
||||
let cr = f64::from(s(2)?) - 128.0;
|
||||
let r = (y + 1.402 * cr).clamp(0.0, 255.0);
|
||||
let g = (y - 0.344_136 * cb - 0.714_136 * cr).clamp(0.0, 255.0);
|
||||
let bl = (y + 1.772 * cb).clamp(0.0, 255.0);
|
||||
for v in [r, g, bl] {
|
||||
push(&mut out, v / 255.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
Image::from_raw(cols, rows, layout, out_sample, out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Build the file meta group (explicit VR little-endian), including the given extra
|
||||
/// `(element, ascii)` pairs before the TransferSyntaxUID.
|
||||
fn meta(b: &mut Vec<u8>, ts: &str, extra: &[(u16, &str)]) {
|
||||
let ui = |e: u16, v: &str, b: &mut Vec<u8>| {
|
||||
let mut s = v.as_bytes().to_vec();
|
||||
if s.len() % 2 == 1 {
|
||||
s.push(0);
|
||||
}
|
||||
b.extend_from_slice(&0x0002u16.to_le_bytes());
|
||||
b.extend_from_slice(&e.to_le_bytes());
|
||||
b.extend_from_slice(b"UI");
|
||||
b.extend_from_slice(&(s.len() as u16).to_le_bytes());
|
||||
b.extend_from_slice(&s);
|
||||
};
|
||||
for (e, v) in extra {
|
||||
ui(*e, v, b);
|
||||
}
|
||||
ui(0x0010, ts, b);
|
||||
}
|
||||
|
||||
fn us(b: &mut Vec<u8>, g: u16, e: u16, v: u16, explicit: bool) {
|
||||
b.extend_from_slice(&g.to_le_bytes());
|
||||
b.extend_from_slice(&e.to_le_bytes());
|
||||
if explicit {
|
||||
b.extend_from_slice(b"US");
|
||||
b.extend_from_slice(&2u16.to_le_bytes());
|
||||
} else {
|
||||
b.extend_from_slice(&2u32.to_le_bytes());
|
||||
}
|
||||
b.extend_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
|
||||
fn cs(b: &mut Vec<u8>, g: u16, e: u16, v: &str, explicit: bool) {
|
||||
let mut s = v.as_bytes().to_vec();
|
||||
if s.len() % 2 == 1 {
|
||||
s.push(0);
|
||||
}
|
||||
b.extend_from_slice(&g.to_le_bytes());
|
||||
b.extend_from_slice(&e.to_le_bytes());
|
||||
if explicit {
|
||||
b.extend_from_slice(b"CS");
|
||||
b.extend_from_slice(&(s.len() as u16).to_le_bytes());
|
||||
} else {
|
||||
b.extend_from_slice(&(s.len() as u32).to_le_bytes());
|
||||
}
|
||||
b.extend_from_slice(&s);
|
||||
}
|
||||
|
||||
/// A DICOM with the given transfer syntax UID and data-set encoding.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn synth(ts: &str, explicit: bool, big: bool, planar: u16, rows: u16, cols: u16, samples: u16, bits: u16, photometric: &str, pixels: &[u8]) -> Vec<u8> {
|
||||
let mut b = vec![0u8; 128];
|
||||
b.extend_from_slice(b"DICM");
|
||||
// One extra meta element before the syntax, so a reader that only looks at the last
|
||||
// group-0002 element takes the wrong one.
|
||||
meta(&mut b, ts, &[(0x0002, "1.2.840.10008.5.1.4.1.1.7")]);
|
||||
let _ = big;
|
||||
us(&mut b, 0x0028, 0x0010, rows, explicit);
|
||||
us(&mut b, 0x0028, 0x0011, cols, explicit);
|
||||
us(&mut b, 0x0028, 0x0002, samples, explicit);
|
||||
us(&mut b, 0x0028, 0x0006, planar, explicit);
|
||||
cs(&mut b, 0x0028, 0x0004, photometric, explicit);
|
||||
us(&mut b, 0x0028, 0x0100, bits, explicit);
|
||||
us(&mut b, 0x0028, 0x0101, bits, explicit);
|
||||
us(&mut b, 0x0028, 0x0102, bits - 1, explicit);
|
||||
us(&mut b, 0x0028, 0x0103, 0, explicit);
|
||||
b.extend_from_slice(&0x7FE0u16.to_le_bytes());
|
||||
b.extend_from_slice(&0x0010u16.to_le_bytes());
|
||||
if explicit {
|
||||
b.extend_from_slice(b"OB");
|
||||
b.extend_from_slice(&0u16.to_le_bytes());
|
||||
}
|
||||
b.extend_from_slice(&(pixels.len() as u32).to_le_bytes());
|
||||
b.extend_from_slice(pixels);
|
||||
b
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_8bit_monochrome() {
|
||||
let b = synth("1.2.840.10008.1.2.1", true, false, 0, 2, 2, 1, 8, "MONOCHROME2", &[0, 51, 102, 255]);
|
||||
let img = decode(&b, &Limits::default()).unwrap();
|
||||
assert_eq!((img.width(), img.height(), img.layout(), img.sample_type()), (2, 2, ChannelLayout::Gray, SampleType::U8));
|
||||
assert_eq!(img.data(), &[0, 51, 102, 255]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_16bit_monochrome_and_scales() {
|
||||
let px: Vec<u8> = [0u16, 32768, 65535].iter().flat_map(|v| v.to_le_bytes()).collect();
|
||||
let b = synth("1.2.840.10008.1.2.1", true, false, 0, 3, 1, 1, 16, "MONOCHROME2", &px);
|
||||
let img = decode(&b, &Limits::default()).unwrap();
|
||||
let v = img.to_u16_samples().unwrap();
|
||||
assert_eq!(v[0], 0);
|
||||
assert!((i32::from(v[1]) - 32768).abs() <= 1);
|
||||
assert_eq!(v[2], 65535);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn monochrome1_is_inverted() {
|
||||
let b = synth("1.2.840.10008.1.2.1", true, false, 0, 2, 1, 1, 8, "MONOCHROME1", &[0, 255]);
|
||||
let img = decode(&b, &Limits::default()).unwrap();
|
||||
assert_eq!(img.data(), &[255, 0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_rgb_interleaved_and_planar() {
|
||||
// 2 pixels: (10,20,30) and (40,50,60).
|
||||
let interleaved = synth("1.2.840.10008.1.2.1", true, false, 0, 1, 2, 3, 8, "RGB", &[10, 20, 30, 40, 50, 60]);
|
||||
let img = decode(&interleaved, &Limits::default()).unwrap();
|
||||
assert_eq!((img.layout(), img.data()), (ChannelLayout::Rgb, &[10u8, 20, 30, 40, 50, 60][..]));
|
||||
// Planar 0: R plane, then G, then B.
|
||||
let planar = synth("1.2.840.10008.1.2.1", true, false, 1, 1, 2, 3, 8, "RGB", &[10, 40, 20, 50, 30, 60]);
|
||||
let img = decode(&planar, &Limits::default()).unwrap();
|
||||
assert_eq!(img.data(), &[10, 20, 30, 40, 50, 60]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_ybr_full_to_rgb() {
|
||||
// White (Y=128, Cb=Cr=128) stays neutral grey.
|
||||
let b = synth("1.2.840.10008.1.2.1", true, false, 0, 1, 1, 3, 8, "YBR_FULL", &[128, 128, 128]);
|
||||
let img = decode(&b, &Limits::default()).unwrap();
|
||||
assert_eq!(img.layout(), ChannelLayout::Rgb);
|
||||
let d = img.data();
|
||||
assert!(d[0].abs_diff(d[1]) <= 1 && d[1].abs_diff(d[2]) <= 1, "{d:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_implicit_vr() {
|
||||
let b = synth("1.2.840.10008.1.2", false, false, 0, 2, 2, 1, 8, "MONOCHROME2", &[0, 51, 102, 255]);
|
||||
let img = decode(&b, &Limits::default()).unwrap();
|
||||
assert_eq!(img.layout(), ChannelLayout::Gray);
|
||||
assert_eq!(img.data(), &[0, 51, 102, 255]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_an_undefined_length_sequence_outside_the_pixel_data() {
|
||||
// Insert an undefined-length SQ (0008,2218) with one item, then a defined-length one,
|
||||
// before the pixel data: the reader must skip both and still decode the image.
|
||||
let mut b = vec![0u8; 128];
|
||||
b.extend_from_slice(b"DICM");
|
||||
meta(&mut b, "1.2.840.10008.1.2.1", &[]);
|
||||
for (g, e, v) in [
|
||||
(0x0028, 0x0010, 1u16),
|
||||
(0x0028, 0x0011, 1),
|
||||
(0x0028, 0x0002, 1),
|
||||
(0x0028, 0x0100, 8),
|
||||
(0x0028, 0x0101, 8),
|
||||
(0x0028, 0x0102, 7),
|
||||
(0x0028, 0x0103, 0),
|
||||
] {
|
||||
us(&mut b, g, e, v, true);
|
||||
}
|
||||
cs(&mut b, 0x0028, 0x0004, "MONOCHROME2", true);
|
||||
// Undefined-length sequence (0008,2211), one undefined-length item containing a nested
|
||||
// defined-length sequence, then the delimiters.
|
||||
b.extend_from_slice(&[0x08, 0x00, 0x11, 0x22, b'S', b'Q', 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0xFF]);
|
||||
b.extend_from_slice(&[0xFE, 0xFF, 0x00, 0xE0, 0xFF, 0xFF, 0xFF, 0xFF]); // item, undefined
|
||||
b.extend_from_slice(&[0x08, 0x00, 0x55, 0x22, b'S', b'Q', 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 1, 2, 3, 4]); // nested, defined
|
||||
b.extend_from_slice(&[0xFE, 0xFF, 0x0D, 0xE0, 0x00, 0x00, 0x00, 0x00]); // item delimitation
|
||||
b.extend_from_slice(&[0xFE, 0xFF, 0xDD, 0xE0, 0x00, 0x00, 0x00, 0x00]); // sequence delimitation
|
||||
// Pixel data (defined length).
|
||||
b.extend_from_slice(&0x7FE0u16.to_le_bytes());
|
||||
b.extend_from_slice(&0x0010u16.to_le_bytes());
|
||||
b.extend_from_slice(b"OB");
|
||||
b.extend_from_slice(&0u16.to_le_bytes());
|
||||
b.extend_from_slice(&1u32.to_le_bytes());
|
||||
b.push(200);
|
||||
let img = decode(&b, &Limits::default()).unwrap();
|
||||
assert_eq!(img.data(), &[200]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncated_and_bad_is_error_not_panic() {
|
||||
let b = synth("1.2.840.10008.1.2.1", true, false, 0, 2, 2, 1, 8, "MONOCHROME2", &[0, 1, 2, 3]);
|
||||
for cut in [0, 4, 128, 132, 140, b.len() - 1] {
|
||||
assert!(decode(&b[..cut.min(b.len())], &Limits::default()).is_err(), "cut {cut}");
|
||||
}
|
||||
assert!(decode(b"not dicom", &Limits::default()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn high_bit_outside_the_allocated_bits_is_an_error_not_a_panic() {
|
||||
let mut b = synth("1.2.840.10008.1.2.1", true, false, 0, 1, 1, 1, 8, "MONOCHROME2", &[7]);
|
||||
let tag = [0x28, 0x00, 0x02, 0x01, b'U', b'S', 0x02, 0x00];
|
||||
let at = b.windows(tag.len()).position(|w| w == tag).unwrap() + tag.len();
|
||||
b[at..at + 2].copy_from_slice(&200u16.to_le_bytes());
|
||||
assert!(decode(&b, &Limits::default()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_compressed_transfer_syntax() {
|
||||
let mut b = synth("1.2.840.10008.1.2.1", true, false, 0, 1, 1, 1, 8, "MONOCHROME2", &[0]);
|
||||
let uncompressed = b"1.2.840.10008.1.2.1\0";
|
||||
let compressed = b"1.2.840.10008.1.2.4\0"; // same length, a compressed family
|
||||
let at = b.windows(uncompressed.len()).position(|w| w == uncompressed).expect("transfer syntax in the fixture");
|
||||
b[at..at + compressed.len()].copy_from_slice(compressed);
|
||||
assert!(matches!(decode(&b, &Limits::default()), Err(CodecError::Unsupported { .. })));
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,8 @@
|
||||
//! 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.
|
||||
//!
|
||||
//! Kodak Cineon (`.cin`), the format DPX grew from, is read and written here too (see its section).
|
||||
|
||||
use crate::Format;
|
||||
use crate::error::CodecError;
|
||||
@@ -344,7 +346,7 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError>
|
||||
// orientation (1 byte), the channel count (1 byte) and up to eight 28-byte channel descriptors
|
||||
// (the first carries the bits per pixel and the line/image dimensions). Image data is three
|
||||
// 10-bit samples packed into each 32-bit word, lines on a word boundary — the same layout as
|
||||
// DPX's 10-bit packing method A, so the unpacking is shared.
|
||||
// DPX's 10-bit packing method A, so the unpacking and packing are shared.
|
||||
|
||||
/// The Cineon magic number (`802A5FD7` hex), big-endian.
|
||||
pub(crate) fn is_cineon(b: &[u8]) -> bool {
|
||||
@@ -409,6 +411,81 @@ pub(crate) fn decode_cineon(bytes: &[u8], limits: &Limits) -> Result<Image, Code
|
||||
Image::from_u16(width, height, layout, &wide)
|
||||
}
|
||||
|
||||
/// Cineon's generic plus film header: where our image data starts.
|
||||
const CINEON_HEADER_LEN: usize = 2048;
|
||||
const CINEON_GENERIC_LEN: u32 = 1024;
|
||||
/// Offset of the first of the eight 28-byte channel descriptors.
|
||||
const CINEON_CHANNEL0: usize = 196;
|
||||
|
||||
/// Encode a Cineon 4.5 frame: the generic header, an undefined film header, then the samples as
|
||||
/// 10-bit code values `round(v × 1023 / 65535)`, three to a big-endian word, left justified
|
||||
/// (packing 5), lines on a word boundary. The plan has already made the image U16 gray or RGB.
|
||||
pub(crate) fn encode_cineon(src: &Image, plan: Plan) -> Result<Vec<u8>, CodecError> {
|
||||
let enc = |msg: String| CodecError::encode(Format::Cineon, msg);
|
||||
let img = src.converted(plan.layout, plan.sample);
|
||||
let (w, h) = img.dimensions();
|
||||
// Channel designators: 0 for black and white, 1–3 for red, green and blue.
|
||||
let designators: &[u8] = match img.layout() {
|
||||
ChannelLayout::Gray => &[0],
|
||||
ChannelLayout::Rgb => &[1, 2, 3],
|
||||
l => return Err(enc(format!("Cineon cannot store {l:?}"))),
|
||||
};
|
||||
let samples = img.to_u16_samples().ok_or_else(|| enc(format!("cannot write {:?} samples at 10 bits", img.sample_type())))?;
|
||||
let codes: Vec<u16> = samples.into_iter().map(|v| narrow(v, 1023)).collect();
|
||||
let per_line = (w as usize).checked_mul(designators.len()).ok_or_else(|| enc("image too large".into()))?;
|
||||
if codes.len() != per_line.checked_mul(h as usize).ok_or_else(|| enc("image too large".into()))? {
|
||||
return Err(enc("the image has the wrong number of samples".into()));
|
||||
}
|
||||
let data_len = line_bytes(per_line, 10, 1).and_then(|l| l.checked_mul(h as usize)).ok_or_else(|| enc("image too large".into()))?;
|
||||
let total =
|
||||
CINEON_HEADER_LEN.checked_add(data_len).and_then(|t| u32::try_from(t).ok()).ok_or_else(|| enc("image too large for a Cineon file (4 GiB)".into()))?;
|
||||
|
||||
let mut wr = Writer { h: vec![0; CINEON_HEADER_LEN] };
|
||||
// File information.
|
||||
wr.u32(0, 0x802A_5FD7);
|
||||
wr.u32(4, CINEON_HEADER_LEN as u32);
|
||||
wr.u32(8, CINEON_GENERIC_LEN);
|
||||
wr.u32(12, CINEON_HEADER_LEN as u32 - CINEON_GENERIC_LEN);
|
||||
wr.u32(16, 0);
|
||||
wr.u32(20, total);
|
||||
wr.string((24, 8), "V4.5");
|
||||
// Image information: default orientation, then each channel's descriptor; the unused ones,
|
||||
// the white point and the primaries are undefined.
|
||||
wr.u8(192, 0);
|
||||
wr.u8(193, designators.len() as u8);
|
||||
wr.undefined(CINEON_CHANNEL0, 420);
|
||||
for (i, &designator) in designators.iter().enumerate() {
|
||||
let c = CINEON_CHANNEL0 + 28 * i;
|
||||
wr.u8(c, 0);
|
||||
wr.u8(c + 1, designator);
|
||||
wr.u8(c + 2, 10);
|
||||
wr.u8(c + 3, 0);
|
||||
wr.u32(c + 4, w);
|
||||
wr.u32(c + 8, h);
|
||||
// Code values 0–1023 stand for printing densities 0–2.048, the Cineon convention. All
|
||||
// four are 32-bit floats here (DPX stores the data values as integers).
|
||||
wr.u32(c + 12, 0f32.to_bits());
|
||||
wr.u32(c + 16, 0f32.to_bits());
|
||||
wr.u32(c + 20, 1023f32.to_bits());
|
||||
wr.u32(c + 24, 2.048f32.to_bits());
|
||||
}
|
||||
// Pixel-interleaved, unsigned, positive, no line or channel padding.
|
||||
wr.u8(681, 5);
|
||||
// Image origination: pixel pitches and gamma unknown.
|
||||
wr.undefined(972, 984);
|
||||
// Film header: manufacturer, type, perforation offset, prefix, count, frame position and rate
|
||||
// unknown; its strings stay empty.
|
||||
wr.undefined(1024, 1036);
|
||||
wr.undefined(1068, 1076);
|
||||
|
||||
let mut out = wr.h;
|
||||
out.try_reserve_exact(data_len).map_err(|_| enc("out of memory".into()))?;
|
||||
for row in codes.chunks_exact(per_line.max(1)) {
|
||||
pack_line(&mut out, row, 10);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// 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();
|
||||
|
||||
@@ -164,7 +164,7 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError>
|
||||
// composite the samples into the flat image (a warning says what was lost).
|
||||
if meta.requirements.has_deep_data {
|
||||
let deep = super::deep_exr::decode_deep(&meta, bytes, limits)?;
|
||||
return super::deep_exr::flatten(&deep);
|
||||
return super::deep_exr::flatten(&deep, limits);
|
||||
}
|
||||
|
||||
let pick = pick_part(&meta);
|
||||
|
||||
@@ -156,9 +156,13 @@ pub(crate) fn layers(bytes: &[u8], limits: &Limits) -> Result<Vec<CryptomatteLay
|
||||
let size = header.layer_size;
|
||||
let (w, h) = (u32::try_from(size.0).unwrap_or(u32::MAX), u32::try_from(size.1).unwrap_or(u32::MAX));
|
||||
limits.check_bytes(w, h, 8)?;
|
||||
// Cache stream lists by resolved layer name: layer_streams() rescans every channel,
|
||||
// and distinct file-controlled keys can all name the same layer, which would turn
|
||||
// channel discovery into O(keys * channels) work and allocate a fresh list each time.
|
||||
let mut streams_for: std::collections::HashMap<String, Vec<String>> = std::collections::HashMap::new();
|
||||
for key in cryptomatte_keys(&header.own_attributes.other) {
|
||||
let Some(name) = attribute(&header.own_attributes.other, &key, "name") else { continue };
|
||||
let channels = layer_streams(header, name.as_str());
|
||||
let channels = streams_for.entry(name.clone()).or_insert_with(|| layer_streams(header, name.as_str())).clone();
|
||||
if channels.is_empty() {
|
||||
continue;
|
||||
}
|
||||
@@ -184,11 +188,15 @@ pub(crate) fn layers(bytes: &[u8], limits: &Limits) -> Result<Vec<CryptomatteLay
|
||||
/// Every `cryptomatte/<key>/` key of a header, in file order, without duplicates.
|
||||
fn cryptomatte_keys(other: &std::collections::HashMap<exr::meta::attribute::Text, exr::meta::attribute::AttributeValue>) -> Vec<String> {
|
||||
let mut keys = Vec::new();
|
||||
// Track seen keys in a set: the attribute names are file-controlled, so membership with
|
||||
// keys.iter().any() is O(n^2) in the number of distinct `cryptomatte/<key>/<field>`
|
||||
// groups. The Vec still records file order; the set only answers "seen before".
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for name in other.keys() {
|
||||
let full = name.to_string();
|
||||
let Some(rest) = full.strip_prefix("cryptomatte/") else { continue };
|
||||
let Some((key, _field)) = rest.split_once('/') else { continue };
|
||||
if !key.is_empty() && !keys.iter().any(|k| k == key) {
|
||||
if !key.is_empty() && seen.insert(key.to_string()) {
|
||||
keys.push(key.to_string());
|
||||
}
|
||||
}
|
||||
@@ -217,14 +225,18 @@ pub(crate) fn decode(bytes: &[u8], layer_name: &str, limits: &Limits) -> Result<
|
||||
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 (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);
|
||||
// f32 channels per set: two ranks of ID and coverage). Include the heap capacity the pair
|
||||
// list can reach: up to one (id, coverage) pair per rank, 8 bytes each. Without this the
|
||||
// returned per-pixel vectors could exceed the caller's max_alloc budget.
|
||||
let ranks = layer.channels.len().saturating_mul(2);
|
||||
let pair_capacity = (ranks as u64).saturating_mul(std::mem::size_of::<(f32, f32)>() as u64);
|
||||
let per_pixel =
|
||||
(layer.channels.len().max(1) as u64).saturating_mul(16).saturating_add(std::mem::size_of::<Vec<(f32, f32)>>() as u64).saturating_add(pair_capacity);
|
||||
limits.check_bytes(w32, h32, per_pixel)?;
|
||||
|
||||
let ex_layer = super::exr::read_layer(bytes, layer.part)?;
|
||||
|
||||
// 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 {
|
||||
|
||||
@@ -22,6 +22,8 @@
|
||||
//! (strings longer than one card on `CONTINUE` cards).
|
||||
//! Tile-compressed files (fpack `ZIMAGE` tables) are recognised and refused.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::Format;
|
||||
use crate::error::CodecError;
|
||||
use crate::fidelity::Plan;
|
||||
@@ -106,13 +108,19 @@ fn parse_card(raw: &[u8]) -> Card {
|
||||
|
||||
struct Header {
|
||||
cards: Vec<Card>,
|
||||
/// First-occurrence index of each non-HIERARCH keyword, so `get` is O(1). The card
|
||||
/// count and the number of looked-up keywords (NAXIS1..NAXISn) are both file-controlled,
|
||||
/// so a linear scan per lookup lets a crafted header multiply commentary cards by axis
|
||||
/// count into quadratic work.
|
||||
index: HashMap<String, usize>,
|
||||
/// Offset of the data unit.
|
||||
data_start: usize,
|
||||
}
|
||||
|
||||
impl Header {
|
||||
fn get(&self, key: &str) -> Option<&Value> {
|
||||
self.cards.iter().find(|c| !c.hierarch && c.key == key).and_then(|c| c.value.as_ref())
|
||||
let c = &self.cards[*self.index.get(key)?];
|
||||
c.value.as_ref()
|
||||
}
|
||||
|
||||
fn int(&self, key: &str) -> Option<i64> {
|
||||
@@ -167,7 +175,15 @@ fn parse_header(b: &[u8], start: usize) -> Result<Header, CodecError> {
|
||||
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 })
|
||||
// Index each non-HIERARCH keyword to its first occurrence, matching the previous
|
||||
// `find(|c| !c.hierarch && c.key == key)` lookup while making `get` O(1).
|
||||
let mut index = HashMap::with_capacity(cards.len());
|
||||
for (i, c) in cards.iter().enumerate() {
|
||||
if !c.hierarch {
|
||||
index.entry(c.key.clone()).or_insert(i);
|
||||
}
|
||||
}
|
||||
Ok(Header { cards, index, data_start })
|
||||
}
|
||||
|
||||
/// The axis lengths (`NAXIS1`, `NAXIS2`, …).
|
||||
|
||||
@@ -14,16 +14,16 @@ use crate::error::CodecError;
|
||||
use crate::image::{ChannelLayout, DecodeWarning, Image};
|
||||
use crate::options::DecodeOptions;
|
||||
|
||||
/// Most frames one GIF may yield. Frames are held in memory, and a tiny canvas could otherwise
|
||||
/// ask for millions of them while staying inside the byte budget.
|
||||
/// Most frames one GIF (or animated WebP) may yield. Frames are held in memory, and a tiny
|
||||
/// canvas could otherwise ask for millions of them while staying inside the byte budget.
|
||||
pub const MAX_GIF_FRAMES: usize = 100_000;
|
||||
|
||||
/// One composited frame of an animation and how long it stays on screen.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct AnimationFrame {
|
||||
pub image: Image,
|
||||
/// On-screen time in milliseconds. GIF counts in hundredths of a second, so this is a
|
||||
/// multiple of 10 (0 when the file gives none).
|
||||
/// On-screen time in milliseconds. GIF counts in hundredths of a second, so there this is a
|
||||
/// multiple of 10 (0 when the file gives none); WebP counts in milliseconds.
|
||||
pub delay_ms: u32,
|
||||
}
|
||||
|
||||
|
||||
@@ -45,6 +45,13 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits, keep_orientation: bool) -> R
|
||||
let sample = if info.sixteen_bit { SampleType::U16 } else { SampleType::U8 };
|
||||
let (w, h) = if keep_orientation { (info.coded_width, info.coded_height) } else { (info.width, info.height) };
|
||||
limits.check(w, h, layout, sample)?;
|
||||
// The delegated decoder allocates the full coded image before applying any clean-aperture
|
||||
// crop or orientation transform, so the coded dimensions must pass the limit policy even
|
||||
// when the displayed (transformed) size is small. Checking only the transformed size let a
|
||||
// tiny visible image hide a large coded allocation.
|
||||
if !keep_orientation && (info.coded_width, info.coded_height) != (w, h) {
|
||||
limits.check(info.coded_width, info.coded_height, layout, sample)?;
|
||||
}
|
||||
let options = photocraft_heif::Options { max_pixels: limits.max_pixels, apply_transforms: !keep_orientation };
|
||||
let decoded = photocraft_heif::decode(bytes, &options).map_err(err)?;
|
||||
// The decoder re-reads the container; trust its output's shape, not the probe's.
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
//! Amiga IFF/ILBM reader (EA "Interchange File Format", "InterLeaved BitMap" and the chunky
|
||||
//! "PBM " variant). Written from the published IFF/ILBM layout: a `FORM` chunk of four-byte
|
||||
//! chunks (`BMHD` bitmap header, `CMAP` palette, `CAMG` Amiga viewport mode, `BODY` pixel data).
|
||||
//! Read-only: modern saves should use PNG.
|
||||
//!
|
||||
//! Supported: 1–8 bitplanes with a palette (or a grey ramp when there is none), masking 0/1/2,
|
||||
//! and both stored (0) and ByteRun1/PackBits (1) compression. Anything else is a clear
|
||||
//! `Unsupported`/`Malformed` error, never a panic.
|
||||
|
||||
use crate::error::CodecError;
|
||||
use crate::format::Format;
|
||||
use crate::image::{ChannelLayout, Image, SampleType};
|
||||
use crate::options::Limits;
|
||||
|
||||
const F: Format = Format::Iff;
|
||||
|
||||
fn bad(msg: impl std::fmt::Display) -> CodecError {
|
||||
CodecError::malformed(F, msg)
|
||||
}
|
||||
|
||||
fn unsupported(msg: impl Into<String>) -> CodecError {
|
||||
CodecError::unsupported(F, msg)
|
||||
}
|
||||
|
||||
fn be32(b: &[u8], at: usize) -> Option<u32> {
|
||||
let s = b.get(at..at.checked_add(4)?)?.try_into().ok()?;
|
||||
Some(u32::from_be_bytes(s))
|
||||
}
|
||||
|
||||
fn be16(b: &[u8], at: usize) -> Option<u16> {
|
||||
let s = b.get(at..at.checked_add(2)?)?.try_into().ok()?;
|
||||
Some(u16::from_be_bytes(s))
|
||||
}
|
||||
|
||||
/// `FORM` + a bitmap type (`ILBM` interleaved, `PBM ` chunky) at offset 8.
|
||||
pub(crate) fn looks_like_iff(b: &[u8]) -> bool {
|
||||
b.get(0..4) == Some(b"FORM") && matches!(b.get(8..12), Some(b"ILBM" | b"PBM "))
|
||||
}
|
||||
|
||||
/// The bitmap header fields decoding needs.
|
||||
struct Header {
|
||||
width: u32,
|
||||
height: u32,
|
||||
planes: u8,
|
||||
masking: u8,
|
||||
compression: u8,
|
||||
transparent: u16,
|
||||
/// `PBM ` stores one palette index per byte, padded to even row lengths.
|
||||
chunky: bool,
|
||||
}
|
||||
|
||||
/// One four-byte chunk's id and byte range.
|
||||
struct Chunk<'a> {
|
||||
id: [u8; 4],
|
||||
data: &'a [u8],
|
||||
}
|
||||
|
||||
/// Walk the `FORM`'s chunks from offset 12 to `end` (the form length), stopping at the first
|
||||
/// malformed length so a hostile file can't run past the buffer.
|
||||
fn chunks(b: &[u8]) -> impl Iterator<Item = Chunk<'_>> {
|
||||
let end = be32(b, 4).map_or(b.len(), |n| (n as usize).saturating_add(8).min(b.len()));
|
||||
let mut pos = 12usize;
|
||||
std::iter::from_fn(move || {
|
||||
if pos + 8 > end {
|
||||
return None;
|
||||
}
|
||||
let id = b.get(pos..pos + 4)?.try_into().ok()?;
|
||||
let len = be32(b, pos + 4)? as usize;
|
||||
let start = pos + 8;
|
||||
let data = b.get(start..start.checked_add(len)?)?;
|
||||
// Chunks are padded to an even length.
|
||||
pos = start.checked_add(len)?.checked_add(len & 1)?;
|
||||
Some(Chunk { id, data })
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_header(b: &[u8], chunky: bool) -> Result<Header, CodecError> {
|
||||
let bmhd = chunks(b).find(|c| &c.id == b"BMHD").ok_or_else(|| bad("no BMHD chunk"))?;
|
||||
let d = bmhd.data;
|
||||
if d.len() < 20 {
|
||||
return Err(bad("BMHD chunk is too short"));
|
||||
}
|
||||
let width = u32::from(be16(d, 0).ok_or_else(|| bad("BMHD width"))?);
|
||||
let height = u32::from(be16(d, 2).ok_or_else(|| bad("BMHD height"))?);
|
||||
let planes = *d.get(8).ok_or_else(|| bad("BMHD planes"))?;
|
||||
let masking = *d.get(9).ok_or_else(|| bad("BMHD masking"))?;
|
||||
let compression = *d.get(10).ok_or_else(|| bad("BMHD compression"))?;
|
||||
if width == 0 || height == 0 {
|
||||
return Err(bad("zero-sized bitmap"));
|
||||
}
|
||||
if !(1..=8).contains(&planes) {
|
||||
return Err(unsupported(format!("{planes} bitplanes (1–8, with a palette)")));
|
||||
}
|
||||
if masking > 2 {
|
||||
return Err(unsupported(format!("masking {masking}")));
|
||||
}
|
||||
if compression > 1 {
|
||||
return Err(unsupported(format!("compression {compression} (only stored and ByteRun1)")));
|
||||
}
|
||||
if chunky && (planes != 8 || masking == 1) {
|
||||
return Err(unsupported("PBM requires 8-bit indices without a mask plane"));
|
||||
}
|
||||
let transparent = be16(d, 12).ok_or_else(|| bad("BMHD transparent colour"))?;
|
||||
Ok(Header { width, height, planes, masking, compression, transparent, chunky })
|
||||
}
|
||||
|
||||
/// ByteRun1 / PackBits decode of one plane row into `out`; returns the bytes consumed. A `0x80`
|
||||
/// control byte is a no-op, as the layout specifies.
|
||||
fn unpack_byte_run1(src: &[u8], out: &mut [u8]) -> Result<usize, CodecError> {
|
||||
let (mut i, mut o) = (0usize, 0usize);
|
||||
while o < out.len() {
|
||||
let n = *src.get(i).ok_or_else(|| bad("truncated ByteRun1 data"))? as i8;
|
||||
i += 1;
|
||||
if n >= 0 {
|
||||
let count = n as usize + 1;
|
||||
let lit = src.get(i..i.checked_add(count).ok_or_else(|| bad("ByteRun1 overflow"))?).ok_or_else(|| bad("truncated ByteRun1 run"))?;
|
||||
if count > out.len() - o {
|
||||
return Err(bad("ByteRun1 run crosses the plane row"));
|
||||
}
|
||||
out[o..o + count].copy_from_slice(lit);
|
||||
i += count;
|
||||
o += count;
|
||||
} else if n != -128 {
|
||||
let count = (1 - n as i32) as usize;
|
||||
let value = *src.get(i).ok_or_else(|| bad("truncated ByteRun1 repeat"))?;
|
||||
i += 1;
|
||||
if count > out.len() - o {
|
||||
return Err(bad("ByteRun1 repeat crosses the plane row"));
|
||||
}
|
||||
out[o..o + count].fill(value);
|
||||
o += count;
|
||||
}
|
||||
}
|
||||
Ok(i)
|
||||
}
|
||||
|
||||
pub(crate) fn decode(b: &[u8], limits: &Limits) -> Result<Image, CodecError> {
|
||||
if !looks_like_iff(b) {
|
||||
return Err(bad("not an IFF/ILBM file (no FORM ... ILBM)"));
|
||||
}
|
||||
let h = parse_header(b, b.get(8..12) == Some(b"PBM "))?;
|
||||
if let Some(camg) = chunks(b).find(|c| &c.id == b"CAMG") {
|
||||
let mode = be32(camg.data, 0).ok_or_else(|| bad("short CAMG chunk"))?;
|
||||
if mode & (0x80 | 0x800) != 0 {
|
||||
return Err(unsupported("EHB and HAM viewport modes"));
|
||||
}
|
||||
}
|
||||
let layout = if h.masking == 0 { ChannelLayout::Rgb } else { ChannelLayout::Rgba };
|
||||
limits.check(h.width, h.height, layout, SampleType::U8)?;
|
||||
|
||||
// Palette: three bytes per colour; missing entries read as black.
|
||||
let cmap = chunks(b).find(|c| &c.id == b"CMAP").map(|c| c.data).filter(|p| !p.is_empty());
|
||||
let colors = 1usize << h.planes;
|
||||
let palette = |idx: usize| -> [u8; 3] {
|
||||
if let Some(p) = cmap {
|
||||
p.get(idx * 3..idx * 3 + 3).map(|c| [c[0], c[1], c[2]]).unwrap_or([0; 3])
|
||||
} else {
|
||||
let v = ((idx as u32 * 255) / (colors as u32 - 1).max(1)) as u8;
|
||||
[v, v, v]
|
||||
}
|
||||
};
|
||||
|
||||
let body = chunks(b).find(|c| &c.id == b"BODY").map(|c| c.data).ok_or_else(|| bad("no BODY chunk"))?;
|
||||
let (w, hh) = (h.width as usize, h.height as usize);
|
||||
// Each plane row is stored as a whole number of 16-bit words.
|
||||
let plane_row = if h.chunky { w.div_ceil(2) * 2 } else { w.div_ceil(16) * 2 };
|
||||
// A masking type 1 bitmap carries one extra (mask) plane per row.
|
||||
let rows_planes: usize = if h.chunky { 1 } else { usize::from(h.planes) + usize::from(h.masking == 1) };
|
||||
|
||||
// Decode every stored plane row up front, then recombine.
|
||||
let total_rows = hh.checked_mul(rows_planes).ok_or_else(|| CodecError::LimitExceeded("bitmap too large".into()))?;
|
||||
let total_bytes = total_rows.checked_mul(plane_row).ok_or_else(|| CodecError::LimitExceeded("bitmap too large".into()))?;
|
||||
if total_bytes as u64 > limits.max_alloc {
|
||||
return Err(CodecError::LimitExceeded("bitmap storage exceeds max_alloc".into()));
|
||||
}
|
||||
Limits::check_plausible(total_bytes as u64, body.len(), if h.compression == 0 { 1 } else { 128 })?;
|
||||
let mut stored = crate::options::alloc_zeroed::<u8>(total_bytes)?;
|
||||
let mut cursor = 0usize;
|
||||
for row in stored.chunks_mut(plane_row.max(1)).take(total_rows) {
|
||||
match h.compression {
|
||||
0 => {
|
||||
let src = body.get(cursor..cursor.checked_add(plane_row).ok_or_else(|| bad("BODY overflow"))?).ok_or_else(|| bad("truncated BODY data"))?;
|
||||
let n = row.len().min(src.len());
|
||||
row[..n].copy_from_slice(&src[..n]);
|
||||
cursor += plane_row;
|
||||
}
|
||||
_ => {
|
||||
let tail = body.get(cursor..).ok_or_else(|| bad("truncated BODY data"))?;
|
||||
cursor += unpack_byte_run1(tail, row)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut out = Vec::new();
|
||||
out.try_reserve_exact(w.saturating_mul(hh).saturating_mul(layout.channels())).map_err(|_| CodecError::LimitExceeded("cannot allocate the image".into()))?;
|
||||
let planes = usize::from(h.planes);
|
||||
for y in 0..hh {
|
||||
for x in 0..w {
|
||||
let index = if h.chunky {
|
||||
usize::from(*stored.get(y * plane_row + x).ok_or_else(|| bad("PBM pixel outside BODY"))?)
|
||||
} else {
|
||||
(0..planes).filter(|&p| bit_at(&stored, (y * rows_planes + p) * plane_row, x)).fold(0usize, |acc, p| acc | (1 << p))
|
||||
};
|
||||
let c = palette(index);
|
||||
out.extend_from_slice(&c);
|
||||
if h.masking != 0 {
|
||||
let opaque = if h.masking == 1 { bit_at(&stored, (y * rows_planes + planes) * plane_row, x) } else { index != usize::from(h.transparent) };
|
||||
out.push(if opaque { 255 } else { 0 });
|
||||
}
|
||||
}
|
||||
}
|
||||
Image::from_u8(h.width, h.height, layout, out)
|
||||
}
|
||||
|
||||
/// The bit for pixel `x` of a stored plane row at `base` (bits run MSB-first).
|
||||
fn bit_at(stored: &[u8], base: usize, x: usize) -> bool {
|
||||
stored.get(base + x / 8).is_some_and(|byte| byte & (0x80 >> (x % 8)) != 0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A `FORM ILBM` with a `BMHD`, `CMAP` and `BODY`. `planes` plane rows per row,
|
||||
/// `compression` 0 (stored) or 1 (ByteRun1), MSB-first.
|
||||
fn ilbm(w: u16, h: u16, planes: u8, palette: &[[u8; 3]], compression: u8, body: &[u8]) -> Vec<u8> {
|
||||
let mut b = b"FORM".to_vec();
|
||||
b.extend_from_slice(&0u32.to_be_bytes()); // patched below
|
||||
b.extend_from_slice(b"ILBM");
|
||||
// BMHD (20 bytes)
|
||||
b.extend_from_slice(b"BMHD");
|
||||
b.extend_from_slice(&20u32.to_be_bytes());
|
||||
b.extend_from_slice(&w.to_be_bytes());
|
||||
b.extend_from_slice(&h.to_be_bytes());
|
||||
b.extend_from_slice(&[0, 0, 0, 0]); // x/y origin
|
||||
b.push(planes);
|
||||
b.push(0); // masking
|
||||
b.push(compression);
|
||||
b.push(0); // pad
|
||||
b.extend_from_slice(&[0, 0]); // transparent colour
|
||||
b.extend_from_slice(&[1, 1]); // aspect
|
||||
b.extend_from_slice(&[0, 0, 0, 0]); // page size
|
||||
// CMAP
|
||||
b.extend_from_slice(b"CMAP");
|
||||
b.extend_from_slice(&((palette.len() * 3) as u32).to_be_bytes());
|
||||
for c in palette {
|
||||
b.extend_from_slice(c);
|
||||
}
|
||||
if palette.len() % 2 == 1 {
|
||||
b.push(0);
|
||||
}
|
||||
// BODY
|
||||
b.extend_from_slice(b"BODY");
|
||||
b.extend_from_slice(&(body.len() as u32).to_be_bytes());
|
||||
b.extend_from_slice(body);
|
||||
if body.len() % 2 == 1 {
|
||||
b.push(0);
|
||||
}
|
||||
let size = (b.len() - 8) as u32;
|
||||
b[4..8].copy_from_slice(&size.to_be_bytes());
|
||||
b
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_a_stored_two_plane_bitmap() {
|
||||
// 2×1, 2 planes: pixel 0 index 0 (plane bits 0,0), pixel 1 index 3 (bits 1,1).
|
||||
// Plane 0 = 0b0100_0000, plane 1 = 0b0100_0000, each padded to 2 bytes.
|
||||
let body = [0b0100_0000, 0, 0b0100_0000, 0];
|
||||
let pal = [[10, 20, 30], [40, 50, 60], [70, 80, 90], [100, 110, 120]];
|
||||
let b = ilbm(2, 1, 2, &pal, 0, &body);
|
||||
assert!(looks_like_iff(&b));
|
||||
let img = decode(&b, &Limits::default()).unwrap();
|
||||
assert_eq!((img.width(), img.height(), img.layout()), (2, 1, ChannelLayout::Rgb));
|
||||
assert_eq!(img.data(), &[10, 20, 30, 100, 110, 120]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_byte_run1_compressed_rows() {
|
||||
// Same image, ByteRun1: literal run of two bytes (0x01) then the two plane bytes each row.
|
||||
let body = [0x01, 0b0100_0000, 0, 0x01, 0b0100_0000, 0];
|
||||
let pal = [[1, 2, 3], [4, 5, 6], [7, 8, 9], [250, 251, 252]];
|
||||
let b = ilbm(2, 1, 2, &pal, 1, &body);
|
||||
let img = decode(&b, &Limits::default()).unwrap();
|
||||
assert_eq!(img.data(), &[1, 2, 3, 250, 251, 252]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_palette_falls_back_to_a_grey_ramp() {
|
||||
// 2×1, 1 plane, no CMAP: indices 0 and 1 → black and white.
|
||||
let body = [0b0100_0000, 0];
|
||||
let b = ilbm(2, 1, 1, &[], 0, &body);
|
||||
let img = decode(&b, &Limits::default()).unwrap();
|
||||
assert_eq!(img.data(), &[0, 0, 0, 255, 255, 255]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pbm_reads_chunky_indices_with_odd_width_padding() {
|
||||
let pal = [[10, 20, 30], [40, 50, 60], [70, 80, 90]];
|
||||
for (compression, body) in [(0, vec![2, 0, 1, 255, 1, 2, 0, 255]), (1, vec![3, 2, 0, 1, 255, 3, 1, 2, 0, 255])] {
|
||||
let mut b = ilbm(3, 2, 8, &pal, compression, &body);
|
||||
b[8..12].copy_from_slice(b"PBM ");
|
||||
let img = decode(&b, &Limits::default()).unwrap();
|
||||
assert_eq!(img.data(), &[70, 80, 90, 10, 20, 30, 40, 50, 60, 40, 50, 60, 70, 80, 90, 10, 20, 30]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transparency_uses_the_mask_plane_or_transparent_index() {
|
||||
for masking in [1, 2] {
|
||||
let mut b = ilbm(2, 1, 1, &[[10, 20, 30], [40, 50, 60]], 0, if masking == 1 { &[0x40, 0, 0x40, 0] } else { &[0x40, 0] });
|
||||
b[29] = masking;
|
||||
let img = decode(&b, &Limits::default()).unwrap();
|
||||
assert_eq!(img.layout(), ChannelLayout::Rgba);
|
||||
assert_eq!(img.data(), &[10, 20, 30, 0, 40, 50, 60, 255]);
|
||||
}
|
||||
let mut b = ilbm(2, 1, 8, &[[10, 20, 30], [40, 50, 60]], 0, &[0, 1]);
|
||||
b[8..12].copy_from_slice(b"PBM ");
|
||||
b[29] = 2;
|
||||
b[32..34].copy_from_slice(&1u16.to_be_bytes());
|
||||
assert_eq!(decode(&b, &Limits::default()).unwrap().data(), &[10, 20, 30, 255, 40, 50, 60, 0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_ham_and_ehb_instead_of_returning_wrong_colours() {
|
||||
for mode in [0x80u32, 0x800] {
|
||||
let mut b = ilbm(1, 1, 1, &[], 0, &[0, 0]);
|
||||
b.extend_from_slice(b"CAMG");
|
||||
b.extend_from_slice(&4u32.to_be_bytes());
|
||||
b.extend_from_slice(&mode.to_be_bytes());
|
||||
let len = (b.len() - 8) as u32;
|
||||
b[4..8].copy_from_slice(&len.to_be_bytes());
|
||||
assert!(matches!(decode(&b, &Limits::default()), Err(CodecError::Unsupported { .. })));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_palette_entries_are_black() {
|
||||
let b = ilbm(1, 1, 2, &[[10, 20, 30]], 0, &[0x80, 0, 0x80, 0]);
|
||||
assert_eq!(decode(&b, &Limits::default()).unwrap().data(), &[0, 0, 0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_input_errors_not_panics() {
|
||||
// Truncations of a valid file never panic.
|
||||
let body = [0b0100_0000, 0, 0b0100_0000, 0];
|
||||
let full = ilbm(2, 1, 2, &[[1, 2, 3], [4, 5, 6], [7, 8, 9], [10, 11, 12]], 0, &body);
|
||||
for cut in [0, 4, 8, 12, 40, full.len() - 1] {
|
||||
assert!(decode(&full[..cut.min(full.len())], &Limits::default()).is_err(), "cut {cut}");
|
||||
}
|
||||
// Too many planes is unsupported.
|
||||
let bad = ilbm(1, 1, 9, &[], 0, &[0, 0]);
|
||||
assert!(matches!(decode(&bad, &Limits::default()), Err(CodecError::Unsupported { .. })));
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,7 @@ use crate::Format;
|
||||
use crate::error::CodecError;
|
||||
use crate::fidelity::Plan;
|
||||
use crate::image::{ChannelLayout, DecodeWarning, Image, Metadata, SampleType};
|
||||
use crate::options::{EncodeOptions, Limits};
|
||||
use crate::options::{EncodeOptions, JpegFormat, Limits};
|
||||
use crate::resolution::{exif_resolution, export_exif, export_xmp, photoshop_resolution, xmp_resolution};
|
||||
|
||||
const F: Format = Format::Jpeg;
|
||||
@@ -256,12 +256,17 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result<Ve
|
||||
let mut enc = jpeg_encoder::Encoder::new(&mut out, opts.jpeg_quality.clamp(1, 100));
|
||||
// CMYK is always written 4:4:4: subsampled 4-component JPEGs are not
|
||||
// decoded consistently across implementations.
|
||||
let sampling = if opts.jpeg_chroma_subsampling && img.layout() == ChannelLayout::Rgb {
|
||||
jpeg_encoder::SamplingFactor::R_4_2_0
|
||||
} else {
|
||||
jpeg_encoder::SamplingFactor::R_4_4_4
|
||||
};
|
||||
let subsampled = opts.jpeg_chroma_subsampling && img.layout() == ChannelLayout::Rgb;
|
||||
let sampling = if subsampled { jpeg_encoder::SamplingFactor::R_4_2_0 } else { jpeg_encoder::SamplingFactor::R_4_4_4 };
|
||||
enc.set_sampling_factor(sampling);
|
||||
// JPEG Options › Format Options. Fitted Huffman tables make a baseline scan non-interleaved
|
||||
// (one scan per component), which zune-jpeg 0.5.15 decodes wrongly with 4:2:0 chroma
|
||||
// (etemesi254/zune-image#419; see `web::encode_jpeg_rgb8`), so a subsampled Baseline
|
||||
// Optimized keeps the standard tables until that decoder is updated. Progressive is fine.
|
||||
let progressive = opts.jpeg_format == JpegFormat::Progressive;
|
||||
enc.set_progressive(progressive);
|
||||
let optimized = opts.jpeg_format == JpegFormat::Optimized && !subsampled;
|
||||
enc.set_optimized_huffman_tables(progressive || optimized);
|
||||
let e = |e: jpeg_encoder::EncodingError| CodecError::encode(F, e);
|
||||
if opts.embed_metadata {
|
||||
if img.layout() != ChannelLayout::Cmyk
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
pub(crate) mod dds;
|
||||
pub(crate) mod dds_bc;
|
||||
pub(crate) mod deep_exr;
|
||||
pub(crate) mod dicom;
|
||||
pub(crate) mod dpx;
|
||||
pub(crate) mod exr;
|
||||
pub(crate) mod exr_cryptomatte;
|
||||
@@ -8,6 +9,7 @@ pub(crate) mod fits;
|
||||
pub(crate) mod gif_frames;
|
||||
pub(crate) mod gif_palette;
|
||||
pub(crate) mod heif;
|
||||
pub(crate) mod iff;
|
||||
pub(crate) mod jpeg;
|
||||
pub(crate) mod pcx;
|
||||
pub(crate) mod png;
|
||||
|
||||
@@ -34,7 +34,7 @@ pub(crate) fn looks_like_pcx(b: &[u8]) -> bool {
|
||||
stride >= min_stride && stride > 0 && (bits != 8 || matches!(planes, 1 | 3)) && (bits == 8 || u32::from(bits) * u32::from(planes) <= 4)
|
||||
}
|
||||
|
||||
/// Read one compressed plane's row. RLE packets never cross a scanline.
|
||||
/// Read one compressed scanline, including all planes. RLE packets never cross a scanline.
|
||||
fn read_row(b: &[u8], pos: &mut usize, out: &mut [u8]) -> Result<(), CodecError> {
|
||||
let mut n = 0;
|
||||
while n < out.len() {
|
||||
@@ -77,9 +77,7 @@ pub(crate) fn decode(b: &[u8], limits: &Limits) -> Result<Image, CodecError> {
|
||||
let mut p = HEADER;
|
||||
let width = w as usize;
|
||||
for y in 0..h as usize {
|
||||
for plane in row.chunks_mut(stride) {
|
||||
read_row(&b[..image_end], &mut p, plane)?;
|
||||
}
|
||||
read_row(&b[..image_end], &mut p, &mut row)?;
|
||||
for x in 0..width {
|
||||
let rgb = if bits == 8 && planes == 3 {
|
||||
[row[x], row[stride + x], row[2 * stride + x]]
|
||||
|
||||
@@ -676,8 +676,8 @@ mod stereo_tests {
|
||||
|
||||
#[test]
|
||||
fn reads_both_defined_views() {
|
||||
assert_eq!(stereo(&png_with(&[0])), Some(PngStereo::Horizontal));
|
||||
assert_eq!(stereo(&png_with(&[1])), Some(PngStereo::Vertical));
|
||||
assert_eq!(stereo(&png_with(&[0])), Some(PngStereo::CrossFuse));
|
||||
assert_eq!(stereo(&png_with(&[1])), Some(PngStereo::DivergingFuse));
|
||||
assert_eq!(stereo(&png_with(&[])), None);
|
||||
// Reserved and unknown mode bytes are not a pairing we can keep.
|
||||
assert_eq!(stereo(&png_with(&[2])), None);
|
||||
@@ -687,6 +687,24 @@ mod stereo_tests {
|
||||
assert_eq!(stereo(b"not a png"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ster_mode_bytes_describe_eye_order_not_vertical_packing() {
|
||||
// PNG Extensions 1.4.0 §3.6: both views occupy horizontal halves.
|
||||
// Each view is eight columns, so the second starts on an eight-column boundary.
|
||||
for (mode, left_pixel, right_pixel) in [(0, [0, 0, 255], [255, 0, 0]), (1, [255, 0, 0], [0, 0, 255])] {
|
||||
let pixels: Vec<u8> = [left_pixel; 8].into_iter().chain([right_pixel; 8]).flatten().collect();
|
||||
let img = Image::from_raw(16, 1, ChannelLayout::Rgb, SampleType::U8, pixels.clone()).unwrap();
|
||||
let pairing = if mode == 0 { PngStereo::CrossFuse } else { PngStereo::DivergingFuse };
|
||||
let opts = EncodeOptions { png_stereo: Some(pairing), ..Default::default() };
|
||||
let bytes = crate::encode(&img, Format::Png, &opts).unwrap();
|
||||
let chunk = bytes.windows(4).position(|w| w == b"sTER").unwrap();
|
||||
assert_eq!(bytes[chunk + 4], mode, "the specification's literal mode byte");
|
||||
let decoded = decode(&bytes, &Limits::default()).unwrap();
|
||||
assert_eq!(decoded.dimensions(), (16, 1));
|
||||
assert_eq!(decoded.data(), pixels);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_broken_crc_is_not_a_stereo_mark() {
|
||||
let mut bytes = png_with(&[0]);
|
||||
@@ -699,7 +717,7 @@ mod stereo_tests {
|
||||
#[test]
|
||||
fn encode_writes_the_chunk_before_idat_and_it_reads_back() {
|
||||
let img = Image::from_raw(2, 1, ChannelLayout::Rgb, SampleType::U8, vec![9; 6]).unwrap();
|
||||
for view in [PngStereo::Horizontal, PngStereo::Vertical] {
|
||||
for view in [PngStereo::CrossFuse, PngStereo::DivergingFuse] {
|
||||
let opts = EncodeOptions { png_stereo: Some(view), ..Default::default() };
|
||||
let bytes = crate::encode(&img, Format::Png, &opts).unwrap();
|
||||
assert_eq!(stereo(&bytes), Some(view));
|
||||
@@ -716,12 +734,12 @@ mod stereo_tests {
|
||||
#[test]
|
||||
fn interlaced_and_indexed_encoders_keep_the_mark() {
|
||||
let img = Image::from_raw(2, 2, ChannelLayout::Rgb, SampleType::U8, vec![9; 12]).unwrap();
|
||||
let opts = EncodeOptions { png_stereo: Some(PngStereo::Vertical), png_interlaced: true, ..Default::default() };
|
||||
let opts = EncodeOptions { png_stereo: Some(PngStereo::DivergingFuse), png_interlaced: true, ..Default::default() };
|
||||
let bytes = crate::encode(&img, Format::Png, &opts).unwrap();
|
||||
assert_eq!(stereo(&bytes), Some(PngStereo::Vertical));
|
||||
assert_eq!(stereo(&bytes), Some(PngStereo::DivergingFuse));
|
||||
let pal = [[255, 0, 0], [0, 0, 255]];
|
||||
let bytes = encode_indexed(2, 1, &[0, 1], &pal, None, None, Some(PngStereo::Horizontal)).unwrap();
|
||||
assert_eq!(stereo(&bytes), Some(PngStereo::Horizontal));
|
||||
let bytes = encode_indexed(2, 1, &[0, 1], &pal, None, None, Some(PngStereo::CrossFuse)).unwrap();
|
||||
assert_eq!(stereo(&bytes), Some(PngStereo::CrossFuse));
|
||||
assert_eq!(palette(&bytes).unwrap().map(|p| p.colors), Some(pal.to_vec()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,3 +88,88 @@ fn rle_row(row: &[u8], bpp: usize, out: &mut Vec<u8>) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn run_packets_at_the_128_pixel_boundary() {
|
||||
for bpp in 1..=4 {
|
||||
for (pixels, headers) in [(1, vec![0]), (2, vec![0x81]), (127, vec![0xfe]), (128, vec![0xff]), (129, vec![0xff, 0])] {
|
||||
let pixel: Vec<u8> = (1..=bpp as u8).collect();
|
||||
let mut encoded = Vec::new();
|
||||
rle_row(&pixel.repeat(pixels), bpp, &mut encoded);
|
||||
let expected: Vec<u8> = headers.into_iter().flat_map(|header| std::iter::once(header).chain(pixel.iter().copied())).collect();
|
||||
assert_eq!(encoded, expected, "{pixels} pixels, {bpp} bytes per pixel");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_packets_at_the_128_pixel_boundary() {
|
||||
for bpp in 1..=4 {
|
||||
for pixels in [127usize, 128, 129] {
|
||||
let row: Vec<u8> = (0..pixels).flat_map(|x| vec![x as u8; bpp]).collect();
|
||||
let mut encoded = Vec::new();
|
||||
rle_row(&row, bpp, &mut encoded);
|
||||
let mut expected = vec![(pixels.min(128) - 1) as u8];
|
||||
expected.extend_from_slice(&row[..pixels.min(128) * bpp]);
|
||||
if pixels > 128 {
|
||||
expected.push(0);
|
||||
expected.extend_from_slice(&row[128 * bpp..]);
|
||||
}
|
||||
assert_eq!(encoded, expected, "{pixels} pixels, {bpp} bytes per pixel");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_packets_stop_before_a_run_of_complete_pixels() {
|
||||
// Equal first channels alone do not make an RGBA run; alpha participates too.
|
||||
let a = [10, 20, 30, 40];
|
||||
let b = [10, 20, 30, 41];
|
||||
let c = [50, 60, 70, 80];
|
||||
let row = [a, b, c, c, a].concat();
|
||||
let mut encoded = Vec::new();
|
||||
rle_row(&row, 4, &mut encoded);
|
||||
assert_eq!(encoded, [vec![1], a.to_vec(), b.to_vec(), vec![0x81], c.to_vec(), vec![0], a.to_vec()].concat());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_sample_types_and_layouts_targa_cannot_store() {
|
||||
for sample in [SampleType::U16, SampleType::F16, SampleType::F32] {
|
||||
let img = Image::new(1, 1, ChannelLayout::Rgb, sample).unwrap();
|
||||
assert!(matches!(encode(&img), Err(CodecError::Encode { format: Format::Tga, .. })), "{sample:?}");
|
||||
}
|
||||
for layout in [ChannelLayout::Cmyk, ChannelLayout::CmykA, ChannelLayout::Lab, ChannelLayout::LabA] {
|
||||
let img = Image::new(1, 1, layout, SampleType::U8).unwrap();
|
||||
assert!(matches!(encode(&img), Err(CodecError::Encode { format: Format::Tga, .. })), "{layout:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dimensions_fit_the_unsigned_16_bit_header() {
|
||||
for (width, height) in [(65535, 1), (1, 65535)] {
|
||||
let img = Image::new(width, height, ChannelLayout::Gray, SampleType::U8).unwrap();
|
||||
let file = encode(&img).unwrap();
|
||||
assert_eq!(&file[12..14], &(width as u16).to_le_bytes());
|
||||
assert_eq!(&file[14..16], &(height as u16).to_le_bytes());
|
||||
}
|
||||
for (width, height) in [(65536, 1), (1, 65536)] {
|
||||
let img = Image::new(width, height, ChannelLayout::Gray, SampleType::U8).unwrap();
|
||||
assert!(matches!(encode(&img), Err(CodecError::Encode { format: Format::Tga, .. })), "{width}x{height}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_buffers_are_rejected_before_targa_encoding() {
|
||||
// Image keeps its buffer private, so malformed buffers must fail at construction.
|
||||
for layout in [ChannelLayout::Gray, ChannelLayout::GrayA, ChannelLayout::Rgb, ChannelLayout::Rgba] {
|
||||
let len = 2 * 2 * layout.channels();
|
||||
for bytes in [len - 1, len + 1] {
|
||||
assert!(matches!(Image::from_u8(2, 2, layout, vec![0; bytes]), Err(CodecError::InvalidImage(_))), "{layout:?}: {bytes} bytes");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +56,56 @@ pub(crate) fn probe(bytes: &[u8], limits: &Limits) -> Result<(u32, u32, Option<V
|
||||
Ok((w, h, dec.exif_metadata().ok().flatten()))
|
||||
}
|
||||
|
||||
/// Whether `bytes` is an animated WebP of more than one frame.
|
||||
pub(crate) fn is_animated(bytes: &[u8]) -> bool {
|
||||
image_webp::WebPDecoder::new(Cursor::new(bytes)).is_ok_and(|d| d.is_animated() && d.num_frames() > 1)
|
||||
}
|
||||
|
||||
/// Every frame of an animated WebP, or `None` when it is a still one (or an animation of one
|
||||
/// frame), which [`decode`] reads whole. The frames come composited onto the canvas by
|
||||
/// `image-webp` (offsets, blending and disposal applied), each a full picture with the file's
|
||||
/// ICC profile and its duration in milliseconds.
|
||||
///
|
||||
/// The first frame must decode. If a later frame is damaged the frames before it are returned
|
||||
/// and the first one carries [`DecodeWarning::Truncated`]. The frame count
|
||||
/// ([`super::gif_frames::MAX_GIF_FRAMES`]) and the total decoded bytes are checked against
|
||||
/// `limits` before each frame is kept.
|
||||
pub(crate) fn decode_frames(bytes: &[u8], limits: &Limits) -> Result<Option<Vec<super::gif_frames::AnimationFrame>>, CodecError> {
|
||||
use super::gif_frames::{AnimationFrame, MAX_GIF_FRAMES};
|
||||
if !is_animated(bytes) {
|
||||
return Ok(None);
|
||||
}
|
||||
let (mut dec, w, h, layout) = open(bytes, limits)?;
|
||||
let size = dec.output_buffer_size().ok_or_else(|| err("image too large"))?;
|
||||
let icc = dec.icc_profile().ok().flatten();
|
||||
let mut buf = alloc_zeroed::<u8>(size)?;
|
||||
let mut out: Vec<AnimationFrame> = Vec::new();
|
||||
let mut damaged = false;
|
||||
for _ in 0..dec.num_frames() {
|
||||
if out.len() >= MAX_GIF_FRAMES {
|
||||
return Err(CodecError::LimitExceeded(format!("more than {MAX_GIF_FRAMES} frames")));
|
||||
}
|
||||
if (size as u64).saturating_mul(out.len() as u64 + 1) > limits.max_alloc {
|
||||
return Err(CodecError::LimitExceeded(format!("{} frames of {w}x{h} exceed max_alloc {}", out.len() + 1, limits.max_alloc)));
|
||||
}
|
||||
let delay_ms = match dec.read_frame(&mut buf) {
|
||||
Ok(d) => d,
|
||||
Err(e) if out.is_empty() => return Err(err(e)),
|
||||
Err(_) => {
|
||||
damaged = true;
|
||||
break;
|
||||
}
|
||||
};
|
||||
let mut image = Image::from_u8(w, h, layout, buf.clone())?;
|
||||
image.icc.clone_from(&icc);
|
||||
out.push(AnimationFrame { image, delay_ms });
|
||||
}
|
||||
if damaged && let Some(first) = out.first_mut() {
|
||||
first.image.warnings.push(DecodeWarning::Truncated { format: F });
|
||||
}
|
||||
Ok(Some(out))
|
||||
}
|
||||
|
||||
pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result<Vec<u8>, CodecError> {
|
||||
let img = src.converted(plan.layout, plan.sample);
|
||||
if !opts.webp_lossless {
|
||||
|
||||
@@ -34,8 +34,12 @@ pub enum Format {
|
||||
Fits,
|
||||
/// DPX (SMPTE 268M Digital Picture Exchange): luma, RGB or RGBA film and video frames.
|
||||
Dpx,
|
||||
/// Kodak Cineon (`.cin`, the 10-bit film-scan format DPX grew from): read-only.
|
||||
/// Kodak Cineon (`.cin`, the 10-bit film-scan format DPX grew from).
|
||||
Cineon,
|
||||
/// DICOM single-frame images (uncompressed): read-only.
|
||||
Dicom,
|
||||
/// Amiga IFF/ILBM (`FORM ... ILBM`): planar bitplanes with a palette, read-only.
|
||||
Iff,
|
||||
}
|
||||
|
||||
/// What a format can hold **and** what this crate reads/writes for it.
|
||||
@@ -68,6 +72,11 @@ pub struct FormatCaps {
|
||||
/// 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::Iff, "Amiga IFF/ILBM is supported for opening legacy Amiga artwork; this crate has no IFF writer, so save as PNG instead."),
|
||||
(
|
||||
Format::Dicom,
|
||||
"DICOM single-frame images are imported for medical review; this crate has no DICOM writer, so save an imported frame as TIFF or 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.",
|
||||
@@ -77,11 +86,6 @@ pub const ASYMMETRIC_EXCEPTIONS: &[(Format, &str)] = &[
|
||||
"AVIF encode uses ravif (pure Rust) but decoding requires dav1d (C); read stays unsupported \
|
||||
until a pure-Rust AV1 decoder is viable. Only enabled with the non-default `avif` feature.",
|
||||
),
|
||||
(
|
||||
Format::Cineon,
|
||||
"Cineon frames are imported for film work; this crate has no Cineon writer, so save an \
|
||||
imported frame as DPX, TIFF or EXR instead.",
|
||||
),
|
||||
(
|
||||
Format::Heif,
|
||||
"HEIC decode uses heic-rs (pure Rust, in the optional photocraft-heif crate behind the \
|
||||
@@ -101,7 +105,7 @@ const TIFF_LAYOUTS: &[ChannelLayout] = &[L::Gray, L::GrayA, L::Rgb, L::Rgba, L::
|
||||
|
||||
impl Format {
|
||||
/// Every format known to the crate (enabled or not).
|
||||
pub const ALL: [Format; 20] = [
|
||||
pub const ALL: [Format; 22] = [
|
||||
Format::Png,
|
||||
Format::Jpeg,
|
||||
Format::Tiff,
|
||||
@@ -122,6 +126,8 @@ impl Format {
|
||||
Format::Fits,
|
||||
Format::Dpx,
|
||||
Format::Cineon,
|
||||
Format::Dicom,
|
||||
Format::Iff,
|
||||
];
|
||||
|
||||
pub fn caps(self) -> FormatCaps {
|
||||
@@ -156,6 +162,8 @@ impl Format {
|
||||
Format::Fits => "FITS",
|
||||
Format::Dpx => "DPX",
|
||||
Format::Cineon => "Cineon",
|
||||
Format::Dicom => "DICOM",
|
||||
Format::Iff => "Amiga IFF",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,6 +191,8 @@ impl Format {
|
||||
Format::Fits => &["fits", "fit", "fts"],
|
||||
Format::Dpx => &["dpx"],
|
||||
Format::Cineon => &["cin"],
|
||||
Format::Dicom => &["dcm", "dicom", "dc3"],
|
||||
Format::Iff => &["iff", "lbm", "ilbm"],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,6 +218,8 @@ impl Format {
|
||||
Format::Fits => "image/fits",
|
||||
Format::Dpx => "image/x-dpx",
|
||||
Format::Cineon => "image/x-cin",
|
||||
Format::Dicom => "application/dicom",
|
||||
Format::Iff => "image/x-iff",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -264,7 +276,9 @@ pub fn caps(format: Format) -> FormatCaps {
|
||||
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 },
|
||||
Format::Cineon => FormatCaps { read: true, write: false, depths: &[S::U16], layouts: &[L::Gray, L::Rgb], alpha: false, ..base },
|
||||
Format::Cineon => FormatCaps { depths: &[S::U16], layouts: &[L::Gray, L::Rgb], alpha: false, ..base },
|
||||
Format::Dicom => FormatCaps { read: true, write: false, depths: &[S::U8, S::U16], layouts: &[L::Gray, L::Rgb], alpha: false, ..base },
|
||||
Format::Iff => FormatCaps { read: true, write: false, depths: &[S::U8], layouts: &[L::Rgb, L::Rgba], alpha: true, ..base },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -322,6 +336,12 @@ pub fn detect(bytes: &[u8]) -> Option<Format> {
|
||||
if crate::codecs::dpx::is_cineon(b) {
|
||||
return Some(Format::Cineon);
|
||||
}
|
||||
if crate::codecs::dicom::is_dicom(b) {
|
||||
return Some(Format::Dicom);
|
||||
}
|
||||
if crate::codecs::iff::looks_like_iff(b) {
|
||||
return Some(Format::Iff);
|
||||
}
|
||||
if b.starts_with(b"SIMPLE =") {
|
||||
return Some(Format::Fits);
|
||||
}
|
||||
|
||||
@@ -40,14 +40,14 @@ pub use crate::image::{
|
||||
ChannelLayout, CryptomatteBuffer, CryptomatteLayer, DecodeWarning, DeepChannel, DeepImage, ExrChannelInfo, ExrPartInfo, Image, Metadata, SampleType,
|
||||
};
|
||||
pub use crate::options::{
|
||||
DdsCompression, DdsOptions, DecodeOptions, DpxBits, EncodeOptions, ExrCompression, Limits, PngCompression, PngStereo, TiffCompression,
|
||||
DdsCompression, DdsOptions, DecodeOptions, DpxBits, EncodeOptions, ExrCompression, JpegFormat, Limits, PngCompression, PngStereo, TiffCompression,
|
||||
};
|
||||
pub use crate::orientation::{exif_orientation, upright_exif, upright_xmp};
|
||||
pub use crate::resolution::{exif_resolution, export_exif, export_xmp, photoshop_resolution, xmp_resolution};
|
||||
pub use crate::source_settings::{WebpEncoding, jpeg_quality, webp_encoding};
|
||||
pub use half::f16;
|
||||
|
||||
use crate::codecs::{dds, dpx, exr, fits, heif, jpeg, pcx, png, pnm, tiff, via_image, webp, xpm};
|
||||
use crate::codecs::{dds, dicom, dpx, exr, fits, heif, iff, 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> {
|
||||
@@ -84,6 +84,8 @@ pub fn decode_as_with(format: Format, bytes: &[u8], opts: &DecodeOptions) -> Res
|
||||
Format::Fits => fits::decode(bytes, l),
|
||||
Format::Dpx => dpx::decode(bytes, l),
|
||||
Format::Cineon => dpx::decode_cineon(bytes, l),
|
||||
Format::Dicom => dicom::decode(bytes, l),
|
||||
Format::Iff => iff::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),
|
||||
@@ -167,6 +169,23 @@ pub fn decode_gif_frames(bytes: &[u8], opts: &DecodeOptions) -> Result<Vec<Anima
|
||||
crate::codecs::gif_frames::decode(bytes, opts)
|
||||
}
|
||||
|
||||
/// Decodes every frame of a WebP (a still one yields one frame with a 0 delay, as [`decode`]
|
||||
/// reads it), each a full picture of the canvas with its duration and the file's ICC profile.
|
||||
/// [`decode`] keeps the first frame only. See [`MAX_GIF_FRAMES`] and `opts.limits` for what is
|
||||
/// refused.
|
||||
pub fn decode_webp_frames(bytes: &[u8], opts: &DecodeOptions) -> Result<Vec<AnimationFrame>, CodecError> {
|
||||
match crate::codecs::webp::decode_frames(bytes, &opts.limits)? {
|
||||
Some(frames) => Ok(frames),
|
||||
None => Ok(vec![AnimationFrame { image: decode_as_with(Format::WebP, bytes, opts)?, delay_ms: 0 }]),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `bytes` is an animated WebP of more than one frame (which [`decode`] opens as its
|
||||
/// first frame, with a warning, and [`decode_webp_frames`] reads whole).
|
||||
pub fn webp_is_animated(bytes: &[u8]) -> bool {
|
||||
crate::codecs::webp::is_animated(bytes)
|
||||
}
|
||||
|
||||
/// The Orientation (1–8) of the page [`decode`] opens from a TIFF or BigTIFF file; 1 when it is
|
||||
/// absent, malformed or out of range.
|
||||
pub fn tiff_orientation(bytes: &[u8]) -> u16 {
|
||||
@@ -282,7 +301,9 @@ pub fn encode(image: &Image, format: Format, opts: &EncodeOptions) -> Result<Vec
|
||||
Format::Dds => dds::encode(image, plan, opts),
|
||||
Format::Fits => fits::encode(image, plan, opts),
|
||||
Format::Dpx => dpx::encode(image, plan, opts),
|
||||
Format::Cineon => Err(CodecError::unsupported(format, "Cineon is import-only; save as DPX, TIFF or EXR instead")),
|
||||
Format::Cineon => dpx::encode_cineon(image, plan),
|
||||
Format::Dicom => Err(CodecError::unsupported(format, "DICOM is import-only; save as TIFF or PNG instead")),
|
||||
Format::Iff => Err(CodecError::unsupported(format, "IFF/ILBM is import-only; save as PNG instead")),
|
||||
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),
|
||||
|
||||
@@ -103,18 +103,18 @@ pub enum PngCompression {
|
||||
/// How a PNG's `sTER` chunk pairs its two views (PNG Extensions 1.4.0, §3.6).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PngStereo {
|
||||
/// Left and right views side by side, left first.
|
||||
Horizontal,
|
||||
/// Left and right views stacked, left on top.
|
||||
Vertical,
|
||||
/// Side by side with the right-eye view first (cross-eyed viewing, mode 0).
|
||||
CrossFuse,
|
||||
/// Side by side with the left-eye view first (parallel viewing, mode 1).
|
||||
DivergingFuse,
|
||||
}
|
||||
|
||||
impl PngStereo {
|
||||
/// The view for an `sTER` mode byte; reserved and unknown values are `None`.
|
||||
pub fn from_mode(mode: u8) -> Option<Self> {
|
||||
match mode {
|
||||
0 => Some(Self::Horizontal),
|
||||
1 => Some(Self::Vertical),
|
||||
0 => Some(Self::CrossFuse),
|
||||
1 => Some(Self::DivergingFuse),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -122,8 +122,8 @@ impl PngStereo {
|
||||
/// The `sTER` mode byte.
|
||||
pub fn mode(self) -> u8 {
|
||||
match self {
|
||||
Self::Horizontal => 0,
|
||||
Self::Vertical => 1,
|
||||
Self::CrossFuse => 0,
|
||||
Self::DivergingFuse => 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -139,6 +139,37 @@ pub enum TiffCompression {
|
||||
PackBits,
|
||||
}
|
||||
|
||||
/// JPEG scan layout: Photoshop's JPEG Options › Format Options.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum JpegFormat {
|
||||
/// Baseline ("Standard"): one sequential scan with the standard Huffman tables.
|
||||
#[default]
|
||||
Baseline,
|
||||
/// Baseline Optimized: one sequential scan with Huffman tables fitted to the image. Only at
|
||||
/// 4:4:4 for now: a 4:2:0 (subsampled RGB) baseline scan keeps the standard tables, because
|
||||
/// fitted tables make jpeg-encoder write one scan per component, which zune-jpeg 0.5.15 (our
|
||||
/// decoder) reads back wrongly when chroma is subsampled (see `web::encode_jpeg_rgb8`).
|
||||
Optimized,
|
||||
/// Progressive: the image refines over several scans as it loads.
|
||||
Progressive,
|
||||
}
|
||||
|
||||
impl JpegFormat {
|
||||
/// `"baseline"`, `"optimized"` or `"progressive"`, as parameters and scripts spell it.
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
JpegFormat::Baseline => "baseline",
|
||||
JpegFormat::Optimized => "optimized",
|
||||
JpegFormat::Progressive => "progressive",
|
||||
}
|
||||
}
|
||||
|
||||
/// The inverse of [`Self::name`].
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
[JpegFormat::Baseline, JpegFormat::Optimized, JpegFormat::Progressive].into_iter().find(|f| f.name() == s)
|
||||
}
|
||||
}
|
||||
|
||||
/// OpenEXR compression (lossless methods only).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum ExrCompression {
|
||||
@@ -244,6 +275,8 @@ pub struct EncodeOptions {
|
||||
pub jpeg_quality: u8,
|
||||
/// JPEG 4:2:0 chroma subsampling (`false` = 4:4:4).
|
||||
pub jpeg_chroma_subsampling: bool,
|
||||
/// JPEG scan layout (baseline, baseline optimized or progressive).
|
||||
pub jpeg_format: JpegFormat,
|
||||
pub png_compression: PngCompression,
|
||||
/// Write Adam7-interlaced PNG.
|
||||
pub png_interlaced: bool,
|
||||
@@ -275,6 +308,7 @@ impl Default for EncodeOptions {
|
||||
EncodeOptions {
|
||||
jpeg_quality: 90,
|
||||
jpeg_chroma_subsampling: true,
|
||||
jpeg_format: JpegFormat::Baseline,
|
||||
png_compression: PngCompression::Default,
|
||||
png_interlaced: false,
|
||||
png_stereo: None,
|
||||
|
||||
@@ -429,8 +429,14 @@ fn xmp_values(xmp: &str, key: &str) -> Vec<(usize, usize)> {
|
||||
continue;
|
||||
};
|
||||
let start = xmp.len() - value.len();
|
||||
let Some(len) = value.find(close) else { continue };
|
||||
// Advance past this value before searching for the next key. Without this, `from`
|
||||
// only moved past the key text, so property-like text repeated inside an unrelated
|
||||
// attribute re-ran this full-suffix `find(close)` for every occurrence, which is
|
||||
// quadratic in the packet size. A missing close means the value is unterminated, so
|
||||
// no later value can parse either; stop scanning in that case.
|
||||
let Some(len) = value.find(close) else { break };
|
||||
out.push((start, start + len));
|
||||
from = start + len + close.len_utf8();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ const DDPF_ALPHAPIXELS: u32 = 0x1;
|
||||
const DDPF_FOURCC: u32 = 0x4;
|
||||
const DDPF_RGB: u32 = 0x40;
|
||||
const DDPF_LUMINANCE: u32 = 0x2_0000;
|
||||
const DDSD_PITCH: u32 = 0x8;
|
||||
|
||||
/// 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> {
|
||||
@@ -31,6 +32,11 @@ fn compressed(width: u32, height: u32, fourcc: &[u8; 4]) -> Vec<u8> {
|
||||
header(width, height, DDPF_FOURCC, fourcc, 0, [0; 4])
|
||||
}
|
||||
|
||||
fn set_pitch(b: &mut [u8], pitch: u32) {
|
||||
b[8..12].copy_from_slice(&(0x1007 | DDSD_PITCH).to_le_bytes());
|
||||
b[20..24].copy_from_slice(&pitch.to_le_bytes());
|
||||
}
|
||||
|
||||
/// 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");
|
||||
@@ -170,6 +176,101 @@ fn decodes_packed_masks_and_luminance() {
|
||||
assert_eq!((im.layout(), im.data()), (ChannelLayout::GrayA, &[10u8, 20, 30, 40][..]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_padded_rgb_rows() {
|
||||
// DWORD-aligned 24-bit rows: the fourth byte is padding, not the next pixel.
|
||||
let mut b = header(1, 2, DDPF_RGB, &[0; 4], 24, [0xff, 0xff00, 0xff_0000, 0]);
|
||||
set_pitch(&mut b, 4);
|
||||
b.extend([255, 0, 0, 99, 0, 255, 0, 88]);
|
||||
let im = decode(&b).unwrap();
|
||||
assert_eq!((im.dimensions(), im.layout()), ((1, 2), ChannelLayout::Rgb));
|
||||
assert_eq!(im.data(), &[255, 0, 0, 0, 255, 0]);
|
||||
|
||||
// Padding can contain whole pixels; only the declared width is decoded.
|
||||
let mut b = header(2, 2, DDPF_RGB, &[0; 4], 16, [0xf800, 0x07e0, 0x001f, 0]);
|
||||
set_pitch(&mut b, 8);
|
||||
for v in [RED, GREEN, WHITE, WHITE, BLUE, BLACK, WHITE, WHITE] {
|
||||
b.extend(v.to_le_bytes());
|
||||
}
|
||||
assert_eq!(decode(&b).unwrap().data(), &[255, 0, 0, 0, 255, 0, 0, 0, 255, 0, 0, 0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_padded_luminance_rows() {
|
||||
let mut b = header(2, 2, DDPF_LUMINANCE, &[0; 4], 8, [0xff, 0, 0, 0]);
|
||||
set_pitch(&mut b, 4);
|
||||
b.extend([10, 20, 99, 98, 30, 40, 97, 96]);
|
||||
let im = decode(&b).unwrap();
|
||||
assert_eq!((im.layout(), im.data()), (ChannelLayout::Gray, &[10u8, 20, 30, 40][..]));
|
||||
|
||||
let mut b = header(1, 2, DDPF_LUMINANCE | DDPF_ALPHAPIXELS, &[0; 4], 16, [0xff, 0, 0, 0xff00]);
|
||||
set_pitch(&mut b, 4);
|
||||
b.extend([10, 20, 99, 98, 30, 40, 97, 96]);
|
||||
let im = decode(&b).unwrap();
|
||||
assert_eq!((im.layout(), im.data()), (ChannelLayout::GrayA, &[10u8, 20, 30, 40][..]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_padded_dx10_rows() {
|
||||
let mut b = dx10(1, 2, 28, 3, 0, 1); // R8G8B8A8_UNORM
|
||||
set_pitch(&mut b, 8);
|
||||
b.extend([1, 2, 3, 4, 99, 98, 97, 96, 5, 6, 7, 8, 95, 94, 93, 92]);
|
||||
assert_eq!(decode(&b).unwrap().data(), &[1, 2, 3, 4, 5, 6, 7, 8]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_pitch_without_the_pitch_flag() {
|
||||
let mut b = header(1, 2, DDPF_RGB, &[0; 4], 24, [0xff, 0xff00, 0xff_0000, 0]);
|
||||
b[20..24].copy_from_slice(&u32::MAX.to_le_bytes());
|
||||
b.extend([255, 0, 0, 0, 255, 0]);
|
||||
assert_eq!(decode(&b).unwrap().data(), &[255, 0, 0, 0, 255, 0]);
|
||||
|
||||
set_pitch(&mut b, 3); // An explicitly tightly packed row is also valid.
|
||||
assert_eq!(decode(&b).unwrap().data(), &[255, 0, 0, 0, 255, 0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compressed_linear_size_is_not_a_row_pitch() {
|
||||
let mut b = compressed(4, 8, b"DXT1");
|
||||
b[8..12].copy_from_slice(&(0x1007u32 | 0x8_0000).to_le_bytes()); // DDSD_LINEARSIZE
|
||||
b[20..24].copy_from_slice(&16u32.to_le_bytes()); // Two 8-byte block rows.
|
||||
b.extend(bc1_block(RED, RED, [0; 4]));
|
||||
b.extend(bc1_block(BLUE, BLUE, [0; 4]));
|
||||
let im = decode(&b).unwrap();
|
||||
assert!(im.data()[..64].as_chunks::<4>().0.iter().all(|p| p == &[255, 0, 0, 255]));
|
||||
assert!(im.data()[64..].as_chunks::<4>().0.iter().all(|p| p == &[0, 0, 255, 255]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_pitch_smaller_than_a_pixel_row() {
|
||||
for pitch in [0, 1, 2] {
|
||||
let mut b = header(1, 2, DDPF_RGB, &[0; 4], 24, [0xff, 0xff00, 0xff_0000, 0]);
|
||||
set_pitch(&mut b, pitch);
|
||||
b.extend([255, 0, 0, 0, 255, 0]);
|
||||
assert!(matches!(decode(&b), Err(CodecError::Malformed { .. })), "pitch {pitch}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_truncated_padded_rows() {
|
||||
let mut b = header(1, 2, DDPF_RGB, &[0; 4], 24, [0xff, 0xff00, 0xff_0000, 0]);
|
||||
set_pitch(&mut b, 4);
|
||||
b.extend([255, 0, 0, 99, 0, 255, 0, 88]);
|
||||
assert!(decode(&b).is_ok());
|
||||
for n in 128..b.len() {
|
||||
assert!(matches!(decode(&b[..n]), Err(CodecError::Malformed { .. })), "decoded {n} of {} bytes", b.len());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_huge_pitch_without_allocating() {
|
||||
let mut b = header(1, 2, DDPF_LUMINANCE, &[0; 4], 8, [0xff, 0, 0, 0]);
|
||||
set_pitch(&mut b, u32::MAX);
|
||||
b.extend([10, 20]);
|
||||
// On 32-bit targets pitch * height overflows; on 64-bit the payload is too short.
|
||||
assert!(matches!(decode(&b), Err(CodecError::Malformed { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_dx10_headers() {
|
||||
let mut b = dx10(1, 1, 28, 3, 0, 1); // R8G8B8A8_UNORM
|
||||
|
||||
@@ -97,6 +97,9 @@ fn level(file: &[u8], i: u32) -> Image {
|
||||
b[12..16].copy_from_slice(&(lh as u32).to_le_bytes());
|
||||
b[16..20].copy_from_slice(&(lw as u32).to_le_bytes());
|
||||
b[28..32].copy_from_slice(&1u32.to_le_bytes());
|
||||
// The pitch field is a row stride for uncompressed textures and the level size for BC.
|
||||
let pitch = if le32(file, 80) & DDPF_FOURCC != 0 { level_bytes(file, lw, lh) } else { lw * le32(file, 88) as usize / 8 };
|
||||
b[20..24].copy_from_slice(&(pitch as u32).to_le_bytes());
|
||||
b.extend(&file[start..start + level_bytes(file, lw, lh)]);
|
||||
decode(&b).unwrap()
|
||||
}
|
||||
|
||||
@@ -60,6 +60,7 @@ fn default_build_formats_all_symmetric() {
|
||||
Format::Hdr,
|
||||
Format::Fits,
|
||||
Format::Dpx,
|
||||
Format::Cineon,
|
||||
];
|
||||
for f in expected {
|
||||
assert!(caps(f).read && caps(f).write, "{f:?}");
|
||||
@@ -102,7 +103,9 @@ fn caps_are_internally_consistent() {
|
||||
let c = caps(f);
|
||||
assert!(!c.depths.is_empty() && !c.layouts.is_empty(), "{f:?}");
|
||||
assert_eq!(c.alpha, c.layouts.iter().any(|l| l.has_alpha()), "{f:?} alpha flag vs layouts");
|
||||
assert!(!f.extensions().is_empty() && !f.name().is_empty() && f.mime_type().starts_with("image/"));
|
||||
// DICOM's registered media type is `application/dicom`; every other format is an image.
|
||||
let mime_ok = f.mime_type().starts_with("image/") || (f == Format::Dicom && f.mime_type() == "application/dicom");
|
||||
assert!(!f.extensions().is_empty() && !f.name().is_empty() && mime_ok, "{f:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,11 +195,27 @@ fn detect_magic_cineon() {
|
||||
b[0..4].copy_from_slice(&[0x80, 0x2A, 0x5F, 0xD7]);
|
||||
assert_eq!(detect(&b), Some(Format::Cineon));
|
||||
assert_eq!(from_extension("scan.CIN"), Some(Format::Cineon));
|
||||
assert!(caps(Format::Cineon).read && !caps(Format::Cineon).write, "Cineon is read-only");
|
||||
assert!(ASYMMETRIC_EXCEPTIONS.iter().any(|(f, _)| *f == Format::Cineon));
|
||||
assert!(caps(Format::Cineon).read && caps(Format::Cineon).write, "Cineon is read and written");
|
||||
assert!(!ASYMMETRIC_EXCEPTIONS.iter().any(|(f, _)| *f == Format::Cineon));
|
||||
// A bare magic is an error, never a panic.
|
||||
assert!(decode_as(Format::Cineon, &b).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_magic_iff() {
|
||||
// FORM + a four-byte length + the bitmap type.
|
||||
assert_eq!(detect(b"FORM\0\0\0\x14ILBM"), Some(Format::Iff));
|
||||
assert_eq!(detect(b"FORM\0\0\0\x14PBM "), Some(Format::Iff));
|
||||
assert_eq!(from_extension("art.LBM"), Some(Format::Iff));
|
||||
assert_eq!(from_extension("art.ilbm"), Some(Format::Iff));
|
||||
assert!(caps(Format::Iff).read && !caps(Format::Iff).write, "IFF is read-only");
|
||||
assert!(caps(Format::Iff).alpha);
|
||||
assert!(caps(Format::Iff).layouts.contains(&photocraft_codecs::ChannelLayout::Rgba));
|
||||
assert!(ASYMMETRIC_EXCEPTIONS.iter().any(|(f, _)| *f == Format::Iff));
|
||||
// A bare FORM without a bitmap type is not IFF.
|
||||
assert_eq!(detect(b"FORM\0\0\0\x14AIFF"), None);
|
||||
assert!(decode_as(Format::Iff, b"FORM\0\0\0\x14ILBM").is_err());
|
||||
}
|
||||
#[test]
|
||||
fn detect_magic_avif() {
|
||||
assert_eq!(detect(b"\0\0\0\x1cftypavif\0\0\0\0avifmif1miaf"), Some(Format::Avif));
|
||||
@@ -204,6 +223,19 @@ fn detect_magic_avif() {
|
||||
assert_eq!(detect(b"\0\0\0\x18ftypmif1\0\0\0\0mif1avif"), Some(Format::Avif), "compatible brand");
|
||||
assert_eq!(detect(b"\0\0\0\x18ftypisom\0\0\0\0isommp41"), None, "mp4 is not avif");
|
||||
}
|
||||
#[test]
|
||||
fn detect_magic_dicom() {
|
||||
let mut b = vec![0u8; 128];
|
||||
b.extend_from_slice(b"DICM");
|
||||
assert_eq!(detect(&b), Some(Format::Dicom));
|
||||
assert_eq!(from_extension("scan.DCM"), Some(Format::Dicom));
|
||||
assert_eq!(from_extension("scan.dc3"), Some(Format::Dicom));
|
||||
assert!(caps(Format::Dicom).read && !caps(Format::Dicom).write);
|
||||
assert!(ASYMMETRIC_EXCEPTIONS.iter().any(|(f, _)| *f == Format::Dicom));
|
||||
// A bare magic is an error, never a panic.
|
||||
assert!(decode_as(Format::Dicom, &b).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_tga_heuristic() {
|
||||
// Uncompressed true-colour 2x2, 24 bpp.
|
||||
|
||||
@@ -384,3 +384,95 @@ fn unsupported_and_damaged_files_error_instead_of_panicking() {
|
||||
assert!(decode_as(Format::Dpx, &bytes).is_err(), "{what}");
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Cineon (Kodak .cin)
|
||||
|
||||
/// A Cineon 4.5 file built field by field: the 1024-byte generic header, a 1024-byte film header,
|
||||
/// then `codes` (interleaved, top line first) three 10-bit values to a big-endian word, left
|
||||
/// justified, each line on a word boundary.
|
||||
fn cineon_bytes(channels: u8, width: u32, height: u32, codes: &[u16]) -> Vec<u8> {
|
||||
let mut b = vec![0u8; 2048];
|
||||
b[0..4].copy_from_slice(&0x802A_5FD7u32.to_be_bytes());
|
||||
b[4..8].copy_from_slice(&2048u32.to_be_bytes());
|
||||
b[193] = channels;
|
||||
b[198] = 10;
|
||||
b[200..204].copy_from_slice(&width.to_be_bytes());
|
||||
b[204..208].copy_from_slice(&height.to_be_bytes());
|
||||
for line in codes.chunks(width as usize * usize::from(channels)) {
|
||||
for chunk in line.chunks(3) {
|
||||
let w = chunk.iter().enumerate().fold(0u32, |w, (i, &c)| w | (u32::from(c) << (22 - 10 * i as u32)));
|
||||
b.extend(w.to_be_bytes());
|
||||
}
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
/// Code values spread over 0..=1023, ends included.
|
||||
fn cineon_codes(n: usize) -> Vec<u16> {
|
||||
(0..n).map(|i| if i == 1 { 1023 } else { ((i as u64 * 2654435761) % 1024) as u16 }).collect()
|
||||
}
|
||||
|
||||
/// Reads back what we wrote: (channels, width, height, code values), from the header's own fields.
|
||||
fn cineon_written(b: &[u8]) -> (u8, u32, u32, Vec<u16>) {
|
||||
let off = be32_at(b, 4) as usize;
|
||||
let (nc, w, h) = (b[193], be32_at(b, 200), be32_at(b, 204));
|
||||
let per_line = w as usize * usize::from(nc);
|
||||
let stride = per_line.div_ceil(3) * 4;
|
||||
let mut codes = Vec::new();
|
||||
for y in 0..h as usize {
|
||||
let line = &b[off + y * stride..];
|
||||
codes.extend((0..per_line).map(|i| ((be32_at(line, i / 3 * 4) >> (22 - 10 * (i % 3) as u32)) & 0x3FF) as u16));
|
||||
}
|
||||
(nc, w, h, codes)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cineon_frames_saved_as_cineon_keep_their_code_values_and_reopen_identically() {
|
||||
// Widths that leave partly filled words at the end of each line.
|
||||
for (channels, layout) in [(1u8, ChannelLayout::Gray), (3, ChannelLayout::Rgb)] {
|
||||
let codes = cineon_codes(5 * 3 * usize::from(channels));
|
||||
let img = decode(&cineon_bytes(channels, 5, 3, &codes)).unwrap();
|
||||
assert_eq!(img.layout(), layout);
|
||||
let out = encode(&img, Format::Cineon, &EncodeOptions::default()).unwrap_or_else(|e| panic!("{layout:?}: {e}"));
|
||||
assert_eq!(detect(&out), Some(Format::Cineon), "{layout:?}");
|
||||
assert_eq!(cineon_written(&out), (channels, 5, 3, codes), "{layout:?}");
|
||||
assert_eq!(decode(&out).unwrap(), img, "{layout:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cineon_header_follows_the_kodak_layout() {
|
||||
let img = decode(&cineon_bytes(3, 4, 2, &cineon_codes(24))).unwrap();
|
||||
let out = encode(&img, Format::Cineon, &EncodeOptions::default()).unwrap();
|
||||
let f32_at = |at: usize| f32::from_bits(be32_at(&out, at));
|
||||
// Generic and film header lengths, user data, total size, version.
|
||||
assert_eq!((be32_at(&out, 4), be32_at(&out, 8), be32_at(&out, 12), be32_at(&out, 16)), (2048, 1024, 1024, 0));
|
||||
assert_eq!(be32_at(&out, 20) as usize, out.len());
|
||||
assert_eq!(&out[24..29], b"V4.5\0");
|
||||
// Upright, three channels, each the universal metric's red, green, blue at 10 bits.
|
||||
assert_eq!((out[192], out[193]), (0, 3));
|
||||
for (i, colour) in [1u8, 2, 3].into_iter().enumerate() {
|
||||
let c = 196 + 28 * i;
|
||||
assert_eq!(&out[c..c + 3], &[0, colour, 10], "channel {i}");
|
||||
assert_eq!((be32_at(&out, c + 4), be32_at(&out, c + 8)), (4, 2), "channel {i}");
|
||||
// Minimum and maximum data value and quantity, all four 32-bit floats.
|
||||
assert_eq!((f32_at(c + 12), f32_at(c + 16), f32_at(c + 20), f32_at(c + 24)), (0.0, 0.0, 1023.0, 2.048), "channel {i}");
|
||||
}
|
||||
// Pixel interleave, 32-bit words left justified, unsigned, positive, no padding.
|
||||
assert_eq!(&out[680..692], &[0, 5, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]);
|
||||
// Two lines of twelve samples: four words each.
|
||||
assert_eq!(out.len(), 2048 + 2 * 16);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn any_image_saves_as_ten_bit_cineon() {
|
||||
// 16-bit samples land on the nearest 10-bit code; colour with alpha drops it, gray stays gray.
|
||||
let wide = Image::from_u16(3, 1, ChannelLayout::Rgba, &[0, 32, 65535, 65535, 30000, 40000, 50000, 0, 1, 2, 3, 4]).unwrap();
|
||||
let out = encode(&wide, Format::Cineon, &EncodeOptions::default()).unwrap();
|
||||
let to_code = |v: u16| (f64::from(v) * 1023.0 / 65535.0).round() as u16;
|
||||
let want: Vec<u16> = [0u16, 32, 65535, 30000, 40000, 50000, 1, 2, 3].into_iter().map(to_code).collect();
|
||||
assert_eq!(cineon_written(&out), (3, 3, 1, want));
|
||||
assert!(fidelity_warnings(&wide, Format::Cineon).contains(&FidelityWarning::AlphaDiscarded));
|
||||
let gray8 = Image::from_u8(2, 1, ChannelLayout::Gray, vec![0, 255]).unwrap();
|
||||
assert_eq!(cineon_written(&encode(&gray8, Format::Cineon, &EncodeOptions::default()).unwrap()), (1, 2, 1, vec![0, 1023]));
|
||||
}
|
||||
|
||||
@@ -79,7 +79,7 @@ fn decodes_4bit_and_monochrome_palettes() {
|
||||
fn rejects_truncated_and_overshooting_rle_without_panicking() {
|
||||
let mut b = header(8, 3, 2, 1, 2);
|
||||
assert!(decode_as(Format::Pcx, &b).is_err(), "header alone is truncated");
|
||||
b.extend([0xc5, 0xff]); // a five-pixel run cannot cross a two-byte row
|
||||
b.extend([0xc7, 0xff]); // a seven-byte run cannot cross a six-byte scanline
|
||||
assert!(decode_as(Format::Pcx, &b).is_err());
|
||||
let mut b = header(8, 3, 2, 1, 2);
|
||||
b.extend([0xc1]); // missing run value
|
||||
@@ -98,3 +98,21 @@ fn refuses_headers_that_exceed_allocation_budget() {
|
||||
b[2] = 0;
|
||||
assert_eq!(detect(&b), None, "unsupported uncompressed encoding");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_rgb_runs_across_planes() {
|
||||
let mut b = header(8, 3, 2, 1, 2);
|
||||
b.extend([0xc4, 0xff, 0xc2, 0]);
|
||||
let im = decode(&b).unwrap();
|
||||
assert_eq!(im.data(), &[255, 255, 0, 255, 255, 0]);
|
||||
let png = encode(&im, Format::Png, &Default::default()).unwrap();
|
||||
assert_eq!(decode(&png).unwrap().data(), im.data());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_four_plane_runs_across_planes() {
|
||||
let mut b = header(1, 4, 2, 1, 2);
|
||||
b[16 + 15 * 3..16 + 16 * 3].copy_from_slice(&[255, 255, 0]);
|
||||
b.extend([0xc8, 0xff]);
|
||||
assert_eq!(decode(&b).unwrap().data(), &[255, 255, 0, 255, 255, 0]);
|
||||
}
|
||||
|
||||
@@ -284,6 +284,17 @@ fn truncated_metadata_never_panics_and_falls_back() {
|
||||
assert_eq!(xmp_resolution(x), None, "{x}");
|
||||
let _ = xmp_with_resolution(x, (1.0, 1.0));
|
||||
}
|
||||
|
||||
// Property-like text repeated inside the packet must not trigger a full-suffix rescan
|
||||
// per occurrence (it used to be quadratic). With the cursor advancing past each located
|
||||
// value, this completes well under a second even with many decoys; a timeout here would
|
||||
// indicate the quadratic scan returned. A clean packet still extracts correctly.
|
||||
let start = std::time::Instant::now();
|
||||
let decoys = " tiff:ResolutionUnit>".repeat(50_000);
|
||||
let _ = xmp_resolution(&decoys);
|
||||
assert!(start.elapsed() < std::time::Duration::from_secs(2), "xmp scan took too long: {:?}", start.elapsed());
|
||||
let clean = "<tiff:XResolution>300/1</tiff:XResolution><tiff:YResolution>300/1</tiff:YResolution><tiff:ResolutionUnit>2</tiff:ResolutionUnit>";
|
||||
near(xmp_resolution(clean), 300.0);
|
||||
}
|
||||
|
||||
proptest! {
|
||||
|
||||
@@ -242,6 +242,48 @@ fn jpeg_444_beats_420_on_chroma_detail() {
|
||||
assert!(psnr(&img, &decode(&a).unwrap()) > psnr(&img, &decode(&b).unwrap()));
|
||||
}
|
||||
|
||||
/// JPEG Options › Format Options: each scan layout decodes back to the same image, Progressive
|
||||
/// writes a progressive frame (SOF2), and Baseline Optimized fits its Huffman tables (smaller at
|
||||
/// 4:4:4; at 4:2:0 it keeps the standard tables, see `JpegFormat::Optimized`).
|
||||
#[test]
|
||||
fn jpeg_format_options_round_trip() {
|
||||
let img = synth(64, 48, ChannelLayout::Rgb, SampleType::U8, 3, 0.5);
|
||||
let sof = |b: &[u8], m: u8| b.windows(2).any(|w| w == [0xFF, m]);
|
||||
for subsampled in [false, true] {
|
||||
let enc = |f| {
|
||||
encode(&img, Format::Jpeg, &EncodeOptions { jpeg_format: f, jpeg_chroma_subsampling: subsampled, jpeg_quality: 90, ..Default::default() }).unwrap()
|
||||
};
|
||||
let (base, opt, prog) = (enc(JpegFormat::Baseline), enc(JpegFormat::Optimized), enc(JpegFormat::Progressive));
|
||||
// The scan layout only changes how the same quantized coefficients are stored, so every
|
||||
// format decodes as faithfully as Baseline.
|
||||
let reference = psnr(&img, &decode(&base).unwrap());
|
||||
for (name, b) in [("optimized", &opt), ("progressive", &prog)] {
|
||||
let back = decode(b).unwrap();
|
||||
assert_eq!(back.dimensions(), img.dimensions(), "{name}");
|
||||
let p = psnr(&img, &back);
|
||||
assert!((p - reference).abs() < 0.5, "{name}, subsampled {subsampled}: {p} vs baseline {reference}");
|
||||
}
|
||||
assert!(sof(&base, 0xC0) && !sof(&base, 0xC2));
|
||||
assert!(sof(&prog, 0xC2), "progressive frame");
|
||||
if subsampled {
|
||||
assert_eq!(opt, base, "4:2:0 keeps the standard tables");
|
||||
} else {
|
||||
assert!(opt.len() < base.len(), "fitted tables are smaller: {} vs {}", opt.len(), base.len());
|
||||
}
|
||||
}
|
||||
// Gray is 4:4:4 too, so Baseline Optimized shrinks it.
|
||||
let gray = synth(64, 48, ChannelLayout::Gray, SampleType::U8, 3, 0.5);
|
||||
let enc = |f| encode(&gray, Format::Jpeg, &EncodeOptions { jpeg_format: f, ..Default::default() }).unwrap();
|
||||
let (base, opt) = (enc(JpegFormat::Baseline), enc(JpegFormat::Optimized));
|
||||
assert!(opt.len() < base.len(), "gray: {} vs {}", opt.len(), base.len());
|
||||
assert!((psnr(&gray, &decode(&opt).unwrap()) - psnr(&gray, &decode(&base).unwrap())).abs() < 0.5);
|
||||
assert_eq!(JpegFormat::parse("progressive"), Some(JpegFormat::Progressive));
|
||||
assert_eq!(JpegFormat::parse(JpegFormat::Optimized.name()), Some(JpegFormat::Optimized));
|
||||
for bad in ["Progressive", "", "standard"] {
|
||||
assert_eq!(JpegFormat::parse(bad), None, "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn webp_lossy_request_is_a_lossy_file() {
|
||||
let img = test_image(ChannelLayout::Rgb, SampleType::U8);
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
//! `decode_webp_frames`: every frame of an animated WebP, composited, with its duration (#1198).
|
||||
|
||||
use photocraft_codecs::{
|
||||
AnimationFrame, ChannelLayout, CodecError, DecodeOptions, DecodeWarning, EncodeOptions, Format, Image, Limits, decode, decode_webp_frames, encode,
|
||||
webp_is_animated,
|
||||
};
|
||||
|
||||
const RED: [u8; 4] = [255, 0, 0, 255];
|
||||
const GREEN: [u8; 4] = [0, 255, 0, 255];
|
||||
const BLUE: [u8; 4] = [0, 0, 255, 255];
|
||||
|
||||
fn frames(bytes: &[u8]) -> Vec<AnimationFrame> {
|
||||
decode_webp_frames(bytes, &DecodeOptions::default()).unwrap()
|
||||
}
|
||||
|
||||
fn solid(c: [u8; 4], w: u32, h: u32) -> Image {
|
||||
Image::from_u8(w, h, ChannelLayout::Rgba, c.repeat((w * h) as usize)).unwrap()
|
||||
}
|
||||
|
||||
fn chunk(id: &[u8; 4], data: &[u8]) -> Vec<u8> {
|
||||
let mut c = id.to_vec();
|
||||
c.extend_from_slice(&(data.len() as u32).to_le_bytes());
|
||||
c.extend_from_slice(data);
|
||||
if data.len() % 2 == 1 {
|
||||
c.push(0);
|
||||
}
|
||||
c
|
||||
}
|
||||
|
||||
fn u24(v: u32) -> [u8; 3] {
|
||||
let b = v.to_le_bytes();
|
||||
[b[0], b[1], b[2]]
|
||||
}
|
||||
|
||||
/// One frame of [`animated`]: the picture, where it sits on the canvas (even offsets, as WebP
|
||||
/// stores them halved), its duration in milliseconds, and whether it is alpha-blended over the
|
||||
/// canvas rather than replacing it.
|
||||
struct Part {
|
||||
image: Image,
|
||||
at: [u32; 2],
|
||||
duration_ms: u32,
|
||||
blend: bool,
|
||||
}
|
||||
|
||||
fn part(c: [u8; 4], w: u32, h: u32, duration_ms: u32) -> Part {
|
||||
Part { image: solid(c, w, h), at: [0, 0], duration_ms, blend: false }
|
||||
}
|
||||
|
||||
/// An animated WebP with a `w`x`h` canvas (transparent background) and one ANMF chunk per part,
|
||||
/// each holding a lossless still from our own encoder.
|
||||
fn animated(w: u32, h: u32, parts: &[Part]) -> Vec<u8> {
|
||||
let mut body = b"WEBP".to_vec();
|
||||
let mut vp8x = vec![0x02 | 0x10, 0, 0, 0]; // animation and alpha flags
|
||||
vp8x.extend_from_slice(&u24(w - 1));
|
||||
vp8x.extend_from_slice(&u24(h - 1));
|
||||
body.extend(chunk(b"VP8X", &vp8x));
|
||||
body.extend(chunk(b"ANIM", &[0, 0, 0, 0, 0, 0])); // background 0, loop forever
|
||||
for p in parts {
|
||||
let still = encode(&p.image, Format::WebP, &EncodeOptions::default()).unwrap();
|
||||
let at = still.windows(4).position(|w| w == b"VP8L").unwrap();
|
||||
let len = u32::from_le_bytes(still[at + 4..at + 8].try_into().unwrap()) as usize;
|
||||
let mut anmf = Vec::new();
|
||||
anmf.extend_from_slice(&u24(p.at[0] / 2));
|
||||
anmf.extend_from_slice(&u24(p.at[1] / 2));
|
||||
anmf.extend_from_slice(&u24(p.image.width() - 1));
|
||||
anmf.extend_from_slice(&u24(p.image.height() - 1));
|
||||
anmf.extend_from_slice(&u24(p.duration_ms));
|
||||
anmf.push(if p.blend { 0 } else { 0x02 }); // bit 1: do not blend
|
||||
anmf.extend(chunk(b"VP8L", &still[at + 8..at + 8 + len]));
|
||||
body.extend(chunk(b"ANMF", &anmf));
|
||||
}
|
||||
let mut out = b"RIFF".to_vec();
|
||||
out.extend_from_slice(&(body.len() as u32).to_le_bytes());
|
||||
out.extend(body);
|
||||
out
|
||||
}
|
||||
|
||||
fn pixel(f: &AnimationFrame, x: u32, y: u32) -> [u8; 4] {
|
||||
let rgba = f.image.to_rgba8();
|
||||
let i = ((y * f.image.width() + x) * 4) as usize;
|
||||
rgba[i..i + 4].try_into().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_still_webp_gives_one_frame_equal_to_decode() {
|
||||
let still = encode(&solid(RED, 6, 4), Format::WebP, &EncodeOptions::default()).unwrap();
|
||||
assert!(!webp_is_animated(&still));
|
||||
let f = frames(&still);
|
||||
assert_eq!(f.len(), 1);
|
||||
assert_eq!(f[0].delay_ms, 0);
|
||||
assert_eq!(f[0].image, decode(&still).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_one_frame_animation_is_not_animated() {
|
||||
let a = animated(6, 4, &[part(RED, 6, 4, 70)]);
|
||||
assert!(!webp_is_animated(&a));
|
||||
assert_eq!(frames(&a).len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_frame_comes_back_in_order_with_its_duration() {
|
||||
let a = animated(6, 4, &[part(RED, 6, 4, 40), part(GREEN, 6, 4, 120), part(BLUE, 6, 4, 7)]);
|
||||
assert!(webp_is_animated(&a));
|
||||
let f = frames(&a);
|
||||
assert_eq!(f.iter().map(|f| f.delay_ms).collect::<Vec<_>>(), [40, 120, 7], "WebP durations are milliseconds");
|
||||
for (f, c) in f.iter().zip([RED, GREEN, BLUE]) {
|
||||
assert_eq!((f.image.width(), f.image.height()), (6, 4));
|
||||
assert_eq!(pixel(f, 0, 0), c);
|
||||
assert_eq!(pixel(f, 5, 3), c);
|
||||
assert_eq!(f.image.warnings, []);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_smaller_frame_is_drawn_over_the_previous_one() {
|
||||
let small = Part { image: solid(BLUE, 2, 2), at: [2, 2], duration_ms: 50, blend: true };
|
||||
let f = frames(&animated(6, 4, &[part(RED, 6, 4, 50), small]));
|
||||
assert_eq!(f.len(), 2);
|
||||
// Alpha blending rounds: within one level.
|
||||
let near = |a: [u8; 4], b: [u8; 4]| a.iter().zip(b).all(|(x, y)| x.abs_diff(y) <= 1);
|
||||
assert!(near(pixel(&f[1], 0, 0), RED), "outside the second frame the first shows through");
|
||||
assert!(near(pixel(&f[1], 2, 2), BLUE));
|
||||
assert!(near(pixel(&f[1], 3, 3), BLUE));
|
||||
assert!(near(pixel(&f[1], 4, 2), RED));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_damaged_later_frame_keeps_the_frames_before_it() {
|
||||
let mut a = animated(6, 4, &[part(RED, 6, 4, 50), part(GREEN, 6, 4, 50), part(BLUE, 6, 4, 50)]);
|
||||
// Break the last frame's VP8L signature byte; the container around it stays whole.
|
||||
let last = a.windows(4).rposition(|w| w == b"VP8L").unwrap();
|
||||
a[last + 8] = 0;
|
||||
let f = frames(&a);
|
||||
assert_eq!(f.len(), 2);
|
||||
assert_eq!(pixel(&f[0], 0, 0), RED);
|
||||
assert_eq!(pixel(&f[1], 0, 0), GREEN);
|
||||
assert_eq!(f[0].image.warnings, [DecodeWarning::Truncated { format: Format::WebP }]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_prefix_of_an_animated_webp_returns() {
|
||||
let a = animated(5, 4, &[part(RED, 5, 4, 50), part(GREEN, 5, 4, 50), part(BLUE, 5, 4, 50)]);
|
||||
for n in 0..a.len() {
|
||||
let _ = decode_webp_frames(&a[..n], &DecodeOptions::default());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dimension_limits_apply() {
|
||||
let a = animated(4, 4, &[part(RED, 4, 4, 50), part(GREEN, 4, 4, 50)]);
|
||||
let opts = DecodeOptions { limits: Limits { max_width: 3, ..Limits::default() }, ..Default::default() };
|
||||
assert!(matches!(decode_webp_frames(&a, &opts), Err(CodecError::LimitExceeded(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_byte_budget_covers_all_frames_together() {
|
||||
// One 4x4 RGBA frame is 64 bytes. The decoder's own canvas counts against the budget too, so
|
||||
// two frames need more than 128; ten frames of the same size must not fit where two do.
|
||||
let two = animated(4, 4, &[part(RED, 4, 4, 50), part(GREEN, 4, 4, 50)]);
|
||||
let ten: Vec<Part> = (0..10).map(|i| part(if i % 2 == 0 { RED } else { BLUE }, 4, 4, 50)).collect();
|
||||
let ten = animated(4, 4, &ten);
|
||||
let opts = DecodeOptions { limits: Limits { max_alloc: 256, ..Limits::default() }, ..Default::default() };
|
||||
assert_eq!(decode_webp_frames(&two, &opts).unwrap().len(), 2);
|
||||
assert!(matches!(decode_webp_frames(&ten, &opts), Err(CodecError::LimitExceeded(_))));
|
||||
}
|
||||
@@ -120,6 +120,29 @@ pub fn read_sample(bytes: &[u8], sample: SampleType, index: usize) -> f32 {
|
||||
}
|
||||
}
|
||||
|
||||
/// [`read_sample`] for the `out.len()` samples from sample index `start` on, matching the sample
|
||||
/// type once instead of per sample.
|
||||
pub fn read_samples(bytes: &[u8], sample: SampleType, start: usize, out: &mut [f32]) {
|
||||
let end = start + out.len();
|
||||
match sample {
|
||||
SampleType::U8 => {
|
||||
for (o, b) in out.iter_mut().zip(&bytes[start..end]) {
|
||||
*o = *b as f32 / 255.0;
|
||||
}
|
||||
}
|
||||
SampleType::U16 => {
|
||||
for (o, b) in out.iter_mut().zip(bytes[start * 2..end * 2].as_chunks::<2>().0) {
|
||||
*o = u16::from_ne_bytes(*b) as f32 / 65535.0;
|
||||
}
|
||||
}
|
||||
SampleType::F32 => {
|
||||
for (o, b) in out.iter_mut().zip(bytes[start * 4..end * 4].as_chunks::<4>().0) {
|
||||
*o = f32::from_ne_bytes(*b);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Write a normalised f32 sample; integer types are clamped and rounded.
|
||||
#[inline]
|
||||
pub fn write_sample(bytes: &mut [u8], sample: SampleType, index: usize, v: f32) {
|
||||
|
||||
@@ -158,7 +158,8 @@ pub fn apply_depth(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer, depth
|
||||
}
|
||||
None => (0.0, 0.0, 0.0),
|
||||
};
|
||||
let out = hue_saturation(c, *hue + dh, (*saturation / 100.0 + ds).clamp(-1.0, 1.0), (*lightness / 100.0).clamp(-1.0, 1.0), *colorize);
|
||||
let sat = colorize_saturation((*saturation / 100.0 + ds).clamp(-1.0, 1.0), *lightness / 100.0, *colorize, depth);
|
||||
let out = hue_saturation_in(c, *hue + dh, sat, (*lightness / 100.0).clamp(-1.0, 1.0), *colorize, depth);
|
||||
range_lightness(out, dl)
|
||||
})
|
||||
}
|
||||
@@ -232,7 +233,8 @@ pub fn apply_depth(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer, depth
|
||||
}
|
||||
}
|
||||
Adjustment::ColorBalance { shadows, midtones, highlights, preserve_luminosity } => {
|
||||
map_rgb(buf, |c| std::array::from_fn(|ch| color_balance_channel([*shadows, *midtones, *highlights], *preserve_luminosity, ch, c[ch])))
|
||||
let curves = BalanceCurve::rgb([*shadows, *midtones, *highlights], *preserve_luminosity);
|
||||
map_rgb(buf, |c| std::array::from_fn(|ch| curves[ch].eval(c[ch])))
|
||||
}
|
||||
Adjustment::SelectiveColor { relative, adjustments } => map_rgb(buf, |c| selective_color(c, *relative, adjustments)),
|
||||
Adjustment::ColorLookup { lut: Some(table), size, tetrahedral, dither, .. } if lut3d_len(*size).is_some_and(|len| table.len() >= len) => {
|
||||
@@ -664,10 +666,31 @@ pub fn hue_range_tables(ranges: &[HueRange; 6]) -> [Vec<f32>; 3] {
|
||||
std::array::from_fn(|i| rows.iter().map(|r| r[i]).collect())
|
||||
}
|
||||
|
||||
/// Hue/Saturation's saturation `s` (-1..1) as [`hue_saturation`] takes it in a document of
|
||||
/// `depth`. A 32-bit Colorize builds its colour with the saturation times `1 - |lightness|`:
|
||||
/// the photoshop corpus's rgb32 hue-saturation-colorize.psd (Saturation 40, Lightness +5) has 0.95
|
||||
/// of the chroma at every lightness, and is exact so, where 8 and 16 bits keep the full saturation
|
||||
/// (their oracles are exact without it). Only a raised Lightness is measured; a lowered one is
|
||||
/// taken to scale the same way.
|
||||
pub fn colorize_saturation(s: f32, lightness: f32, colorize: bool, depth: Option<SampleType>) -> f32 {
|
||||
// Clamped here too: a PSD can store a saturation beyond ±100, and the GPU scales it unclamped.
|
||||
if colorize && depth == Some(SampleType::F32) { s.clamp(-1.0, 1.0) * (1.0 - lightness.abs().min(1.0)) } else { s }
|
||||
}
|
||||
|
||||
/// One Hue/Saturation evaluation: hue shift in degrees, saturation and lightness in -1..=1.
|
||||
/// Colorize sets the hue and saturation instead of shifting them; at 0% it leaves only the
|
||||
/// lightness, as in Photoshop (#2326). Ticking Colorize in the dialog starts saturation at 25%.
|
||||
pub fn hue_saturation(c: [f32; 3], hue: f32, s: f32, l: f32, colorize: bool) -> [f32; 3] {
|
||||
hue_saturation_in(c, hue, s, l, colorize, None)
|
||||
}
|
||||
|
||||
/// [`hue_saturation`] in a document of `depth`. At 32 bits Photoshop doesn't clip the raised
|
||||
/// saturation, so saturated colours leave 0..1 (red with Hue +40, Saturation +25, Lightness -10
|
||||
/// becomes (1.0477, 0.649, -0.1477)), and it scales each colour's chroma around its lightness by
|
||||
/// [`hdr_saturation_factor`], so colours at or above lightness 1 keep their hue too: the photoshop
|
||||
/// corpus's rgb32 hue-saturation.psd is exact so.
|
||||
pub fn hue_saturation_in(c: [f32; 3], hue: f32, s: f32, l: f32, colorize: bool, depth: Option<SampleType>) -> [f32; 3] {
|
||||
let hdr = depth == Some(SampleType::F32);
|
||||
let lighten = |v: f32| if l > 0.0 { v + (1.0 - v) * l } else { v * (1.0 + l) };
|
||||
if colorize {
|
||||
// Colorize builds the colour at the lightened (or darkened) lightness, so black with
|
||||
@@ -678,14 +701,36 @@ pub fn hue_saturation(c: [f32; 3], hue: f32, s: f32, l: f32, colorize: bool) ->
|
||||
// Photoshop applies Lightness first, then rotates the hue and scales the saturation of the
|
||||
// result: Hue +40, Saturation +25, Lightness -10 turns (240, 160, 30) into (173, 243, 0), not
|
||||
// (168, 230, 14) (Photoshop 27.11 corpus file adjustments/rgb8/hue-saturation).
|
||||
let (hh, ss, ll) = rgb_to_hsl(c.map(lighten));
|
||||
let lc = c.map(lighten);
|
||||
let (hh, ss, ll) = rgb_to_hsl(lc);
|
||||
let hh = (hh + hue / 360.0).rem_euclid(1.0);
|
||||
if hdr {
|
||||
// The chroma, not the HSL saturation, whose `2 - max - min` divisor is 0 at lightness 1
|
||||
// and negative above it: (2, 0, 0) with Hue +40 becomes (2, 1.333, 0) in Photoshop.
|
||||
let d = (lc[0].max(lc[1]).max(lc[2]) - lc[0].min(lc[1]).min(lc[2])) * hdr_saturation_factor(s);
|
||||
return hue_rgb(hh, ll - d / 2.0, ll + d / 2.0);
|
||||
}
|
||||
// Raising saturation divides by 1 - s, lowering it multiplies by 1 + s: +50 % doubles it
|
||||
// (measured in Photoshop 27.11); +100 % saturates any colour that has some.
|
||||
let ss = if s > 0.0 { ss / (1.0 - s).max(1.0 / 512.0) } else { ss * (1.0 + s) }.clamp(0.0, 1.0);
|
||||
hsl_to_rgb(hh, ss, ll)
|
||||
}
|
||||
|
||||
/// The factor 32-bit Hue/Saturation scales chroma by for saturation `s` (-1..1), as Photoshop
|
||||
/// computes it (measured at every whole percent from -100 to +100 on a 32-bit document): lowering
|
||||
/// cuts `s` to 1/256 steps, raising cuts it to 254 steps out of 255, takes 255 / (255 - steps) to
|
||||
/// 1/256 and stops at 32767/256, so +25 % gives 340/256, +99 % 63.75 and +100 % 127.996.
|
||||
/// compose.wgsl's Hue/Saturation does the same in whole numbers.
|
||||
pub fn hdr_saturation_factor(s: f32) -> f32 {
|
||||
// The 1e-4 keeps a master plus range sum on its step: -0.46 + 0.96 is 0.49999997 in f32.
|
||||
let s = s.clamp(-1.0, 1.0);
|
||||
if s <= 0.0 {
|
||||
return 1.0 - (-s * 256.0 + 1e-4).floor() / 256.0;
|
||||
}
|
||||
let steps = (s * 254.0 + 1e-4).floor() as u32;
|
||||
(65280 / 255u32.saturating_sub(steps).max(1)).min(32767) as f32 / 256.0
|
||||
}
|
||||
|
||||
/// A colour range's Lightness, applied after the master edits. It moves each channel towards the
|
||||
/// pixel's brightest channel when raised and towards its darkest when lowered, not towards white
|
||||
/// or black: Blues +50 turns (0, 29, 115) into (57, 71, 115), Blues -50 turns (102, 128, 204) into
|
||||
@@ -786,48 +831,89 @@ pub fn lut3d_sample(lut: &[f32], n: usize, c: [f32; 3], tetrahedral: bool) -> [f
|
||||
/// - Preserve Luminosity on: each channel's black point is (max − own) shadows levels, its white
|
||||
/// point 255 − (own − min) highlights levels, and m = own − (max + min) / 2 midtones.
|
||||
///
|
||||
/// A gamma > 1 starts with slope 2^gamma at black ([`balance_gamma`]: up to 18 of 255 brighter
|
||||
/// A gamma > 1 starts with slope 2^gamma at black (up to 18 of 255 brighter
|
||||
/// near black without it). When the black point reaches the white point, input
|
||||
/// at or above the white point is white and the rest black.
|
||||
pub fn color_balance_channel(tones: [[f32; 3]; 3], preserve_luminosity: bool, ch: usize, v: f32) -> f32 {
|
||||
let [shadows, midtones, highlights] = tones.map(|t| t.map(|s| if s.is_finite() { s.clamp(-100.0, 100.0) } else { 0.0 }));
|
||||
let own = |t: [f32; 3]| t.get(ch).copied().unwrap_or(0.0);
|
||||
let max = |t: [f32; 3]| t[0].max(t[1]).max(t[2]);
|
||||
let min = |t: [f32; 3]| t[0].min(t[1]).min(t[2]);
|
||||
let (black, white, m) = if preserve_luminosity {
|
||||
(max(shadows) - own(shadows), own(highlights) - min(highlights), own(midtones) - (max(midtones) + min(midtones)) / 2.0)
|
||||
} else {
|
||||
((-own(shadows)).max(0.0), own(highlights).max(0.0), own(midtones) + (own(shadows) + own(highlights)) / 2.0)
|
||||
};
|
||||
let (b, w) = (black / 255.0, 1.0 - white / 255.0);
|
||||
let v = if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 };
|
||||
if v >= w {
|
||||
return 1.0;
|
||||
}
|
||||
if v <= b {
|
||||
return 0.0;
|
||||
}
|
||||
balance_gamma((v - b) / (w - b), 2f32.powf(m / 100.0))
|
||||
BalanceCurve::new(tones, preserve_luminosity, ch).eval(v)
|
||||
}
|
||||
|
||||
/// The midtone gamma of Color Balance's per-channel Levels (`u` in 0..1): `u^(1/gamma)`, except
|
||||
/// that a gamma > 1 (which would start with infinite slope) starts at slope 2^gamma and joins the
|
||||
/// power curve in a cubic Hermite at twice the input where that line meets it (Photoshop,
|
||||
/// measured on a 16-bit ramp at gamma 1.07..2: within 0.02 of 255).
|
||||
fn balance_gamma(u: f32, gamma: f32) -> f32 {
|
||||
let e = 1.0 / gamma.max(0.01);
|
||||
let power = u.powf(e);
|
||||
if gamma <= 1.0 {
|
||||
return power;
|
||||
/// One channel's Color Balance curve (see [`color_balance_channel`]), with the terms that don't
|
||||
/// depend on the sample worked out once rather than per pixel.
|
||||
pub struct BalanceCurve {
|
||||
/// Input black and white points.
|
||||
b: f32,
|
||||
w: f32,
|
||||
/// The power curve's exponent, 1 / gamma.
|
||||
e: f32,
|
||||
/// The Hermite join of a gamma > 1.
|
||||
knee: Option<Knee>,
|
||||
}
|
||||
|
||||
/// Where a gamma > 1 curve's Hermite join ends (`u0`), its starting slope 2^gamma (`s0`), and the
|
||||
/// power curve's value (`p`) and slope (`d`) at `u0`.
|
||||
#[derive(Clone, Copy)]
|
||||
struct Knee {
|
||||
u0: f32,
|
||||
s0: f32,
|
||||
p: f32,
|
||||
d: f32,
|
||||
}
|
||||
|
||||
impl BalanceCurve {
|
||||
/// The red, green and blue curves.
|
||||
pub fn rgb(tones: [[f32; 3]; 3], preserve_luminosity: bool) -> [Self; 3] {
|
||||
std::array::from_fn(|ch| Self::new(tones, preserve_luminosity, ch))
|
||||
}
|
||||
let s0 = 2f32.powf(gamma);
|
||||
let u0 = (2.0 * s0.powf(1.0 / (e - 1.0))).min(1.0);
|
||||
if u0.is_nan() || u0 <= 0.0 || u >= u0 {
|
||||
return power;
|
||||
|
||||
/// The curve of channel `ch` (0 red, 1 green, 2 blue) for the shadows, midtones and
|
||||
/// highlights slider triples `tones`.
|
||||
pub fn new(tones: [[f32; 3]; 3], preserve_luminosity: bool, ch: usize) -> Self {
|
||||
let [shadows, midtones, highlights] = tones.map(|t| t.map(|s| if s.is_finite() { s.clamp(-100.0, 100.0) } else { 0.0 }));
|
||||
let own = |t: [f32; 3]| t.get(ch).copied().unwrap_or(0.0);
|
||||
let max = |t: [f32; 3]| t[0].max(t[1]).max(t[2]);
|
||||
let min = |t: [f32; 3]| t[0].min(t[1]).min(t[2]);
|
||||
let (black, white, m) = if preserve_luminosity {
|
||||
(max(shadows) - own(shadows), own(highlights) - min(highlights), own(midtones) - (max(midtones) + min(midtones)) / 2.0)
|
||||
} else {
|
||||
((-own(shadows)).max(0.0), own(highlights).max(0.0), own(midtones) + (own(shadows) + own(highlights)) / 2.0)
|
||||
};
|
||||
// The midtone gamma of the channel's Levels: `u^(1/gamma)`, except that a gamma > 1
|
||||
// (which would start with infinite slope) starts at slope 2^gamma and joins the power
|
||||
// curve in a cubic Hermite at twice the input where that line meets it (Photoshop,
|
||||
// measured on a 16-bit ramp at gamma 1.07..2: within 0.02 of 255).
|
||||
let gamma = 2f32.powf(m / 100.0);
|
||||
let e = 1.0 / gamma.max(0.01);
|
||||
let knee = if gamma > 1.0 {
|
||||
let s0 = 2f32.powf(gamma);
|
||||
let u0 = (2.0 * s0.powf(1.0 / (e - 1.0))).min(1.0);
|
||||
(!u0.is_nan() && u0 > 0.0).then(|| Knee { u0, s0, p: u0.powf(e), d: e * u0.powf(e - 1.0) })
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Self { b: black / 255.0, w: 1.0 - white / 255.0, e, knee }
|
||||
}
|
||||
|
||||
/// The curve at `v` (0..1).
|
||||
pub fn eval(&self, v: f32) -> f32 {
|
||||
let (b, w) = (self.b, self.w);
|
||||
let v = if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 };
|
||||
if v >= w {
|
||||
return 1.0;
|
||||
}
|
||||
if v <= b {
|
||||
return 0.0;
|
||||
}
|
||||
let u = (v - b) / (w - b);
|
||||
match self.knee {
|
||||
Some(Knee { u0, s0, p, d }) if u < u0 => {
|
||||
let t = u / u0;
|
||||
let (t2, t3) = (t * t, t * t * t);
|
||||
((t3 - 2.0 * t2 + t) * u0 * s0 + (3.0 * t2 - 2.0 * t3) * p + (t3 - t2) * u0 * d).clamp(0.0, 1.0)
|
||||
}
|
||||
_ => u.powf(self.e),
|
||||
}
|
||||
}
|
||||
let (p, d, t) = (u0.powf(e), e * u0.powf(e - 1.0), u / u0);
|
||||
let (t2, t3) = (t * t, t * t * t);
|
||||
((t3 - 2.0 * t2 + t) * u0 * s0 + (3.0 * t2 - 2.0 * t3) * p + (t3 - t2) * u0 * d).clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
/// How much a colour belongs to each Selective Color range: reds, yellows, greens, cyans,
|
||||
@@ -898,7 +984,9 @@ fn map_rgb(buf: &mut Buffer, f: impl Fn([f32; 3]) -> [f32; 3]) {
|
||||
#[inline]
|
||||
fn lut(table: &[f32], v: f32) -> f32 {
|
||||
let x = v.clamp(0.0, 1.0) * (table.len() - 1) as f32;
|
||||
let i = x.floor() as usize;
|
||||
// Never negative (NaN casts to 0, as its floor did), so truncating is `floor`, without the
|
||||
// libm call baseline x86-64 (no SSE4.1) makes for it.
|
||||
let i = x as usize;
|
||||
let j = (i + 1).min(table.len() - 1);
|
||||
let f = x - i as f32;
|
||||
table[i] * (1.0 - f) + table[j] * f
|
||||
@@ -1389,7 +1477,11 @@ pub fn hsl_to_rgb(h: f32, s: f32, l: f32) -> [f32; 3] {
|
||||
return [l; 3];
|
||||
}
|
||||
let q = if l < 0.5 { l * (1.0 + s) } else { l + s - l * s };
|
||||
let p = 2.0 * l - q;
|
||||
hue_rgb(h, 2.0 * l - q, q)
|
||||
}
|
||||
|
||||
/// The colour of hue `h` (0..1) with its smallest channel at `p` and its largest at `q`.
|
||||
fn hue_rgb(h: f32, p: f32, q: f32) -> [f32; 3] {
|
||||
let f = |mut t: f32| {
|
||||
t = t.rem_euclid(1.0);
|
||||
if t < 1.0 / 6.0 {
|
||||
@@ -1498,6 +1590,89 @@ mod tone_tests {
|
||||
[b.px[0][0], b.px[0][1], b.px[0][2]]
|
||||
}
|
||||
|
||||
/// Photoshop's 32-bit Hue/Saturation (photoshop corpus rgb32 hue-saturation.psd: hue +40,
|
||||
/// saturation +25, lightness -10): saturated colours leave 0..1, and the saturation factor is
|
||||
/// 1 / (1 - 63/255), so red becomes (1.0477, 0.649, -0.1477), where 8 bits clip to (0.9, 0.6, 0).
|
||||
#[test]
|
||||
fn thirty_two_bit_hue_saturation_is_unclipped() {
|
||||
let adj = Adjustment::HueSaturation { hue: 40.0, saturation: 25.0, lightness: -10.0, colorize: false, ranges: HueRange::defaults() };
|
||||
let near = |got: [f32; 3], want: [f32; 3]| got.iter().zip(want).all(|(g, w)| (g - w).abs() < 1e-3);
|
||||
for (c, want) in
|
||||
[([1.0, 0.0, 0.0], [1.0477, 0.649, -0.1477]), ([0.9301, 0.6105, 0.2541], [0.7804, 0.9369, 0.1289]), ([0.0, 0.3864, 1.0], [0.1871, -0.1477, 1.0477])]
|
||||
{
|
||||
assert!(near(hdr(&adj, c), want), "{c:?}: {:?}", hdr(&adj, c));
|
||||
}
|
||||
let mut b = Buffer::filled(crate::Rect::new(0, 0, 1, 1), [1.0, 0.0, 0.0, 1.0]);
|
||||
apply_depth(&adj, &mut b, Transfer::Srgb, Some(SampleType::U16));
|
||||
assert!(b.px[0].iter().all(|v| (0.0..=1.0).contains(v)), "16 bits clip: {:?}", b.px[0]);
|
||||
}
|
||||
|
||||
/// Photoshop's 32-bit Colorize (photoshop corpus rgb32 hue-saturation-colorize.psd: hue -160,
|
||||
/// saturation 40, lightness +5): red becomes (0.3445, 0.5852, 0.7055), 0.95 of the chroma an
|
||||
/// 8-bit Colorize gives at the same lightness, (0.335, 0.5883, 0.715).
|
||||
#[test]
|
||||
fn thirty_two_bit_colorize_scales_the_saturation() {
|
||||
let adj = Adjustment::HueSaturation { hue: -160.0, saturation: 40.0, lightness: 5.0, colorize: true, ranges: HueRange::defaults() };
|
||||
let near = |got: [f32; 3], want: [f32; 3]| got.iter().zip(want).all(|(g, w)| (g - w).abs() < 2e-3);
|
||||
let red = hdr(&adj, [1.0, 0.0, 0.0]);
|
||||
assert!(near(red, [0.3445, 0.5852, 0.7055]), "{red:?}");
|
||||
let mut b = Buffer::filled(crate::Rect::new(0, 0, 1, 1), [1.0, 0.0, 0.0, 1.0]);
|
||||
apply_depth(&adj, &mut b, Transfer::Srgb, Some(SampleType::U16));
|
||||
assert!(near([b.px[0][0], b.px[0][1], b.px[0][2]], [0.335, 0.5883, 0.715]), "16-bit keeps the full saturation: {:?}", b.px[0]);
|
||||
}
|
||||
|
||||
/// Photoshop's 32-bit saturation factor (Hue/Saturation on red (1, 0, 0) in a 32-bit document,
|
||||
/// read back from the saved float TIFF, exact in 1/512): red's R is 0.5 + 0.5 x the factor.
|
||||
#[test]
|
||||
fn thirty_two_bit_saturation_steps_match_photoshop() {
|
||||
for (sat, want) in [
|
||||
(10.0, 539.0 / 512.0),
|
||||
(75.0, 2.4609375),
|
||||
(90.0, 2673.0 / 512.0),
|
||||
(99.0, 32.375),
|
||||
(100.0, 33023.0 / 512.0),
|
||||
(-10.0, 487.0 / 512.0),
|
||||
(-25.0, 0.875),
|
||||
(-50.0, 0.75),
|
||||
(-100.0, 0.5),
|
||||
] {
|
||||
let adj = Adjustment::HueSaturation { hue: 0.0, saturation: sat, lightness: 0.0, colorize: false, ranges: HueRange::defaults() };
|
||||
let got = hdr(&adj, [1.0, 0.0, 0.0])[0];
|
||||
assert!((got - want).abs() < 1e-4, "saturation {sat}: {got}, Photoshop {want}");
|
||||
}
|
||||
}
|
||||
|
||||
/// 32-bit colours at or above HSL lightness 1 (red at +1 stop, (2, 0, 0)) keep their hue and
|
||||
/// get their chroma scaled like any other, as in Photoshop; they used to turn NaN or grey.
|
||||
#[test]
|
||||
fn thirty_two_bit_hue_saturation_keeps_bright_colours() {
|
||||
let pale = [280.0 / 255.0, 230.0 / 255.0, 256.0 / 255.0];
|
||||
let near = |got: [f32; 3], want: [f32; 3]| got.iter().zip(want).all(|(g, w)| (g - w).abs() <= 1e-3 * w.abs().max(1.0));
|
||||
for (hue, sat, c, want) in [
|
||||
(40.0, 0.0, [2.0, 0.0, 0.0], [2.0, 1.33301, 0.0]),
|
||||
(40.0, 0.0, pale, [1.09804, 0.930687, 0.901961]),
|
||||
(0.0, 25.0, [2.0, 0.0, 0.0], [2.32812, -0.328125, -0.328125]),
|
||||
(0.0, 25.0, pale, [1.13021, 0.869792, 1.00521]),
|
||||
(0.0, -25.0, [2.0, 0.0, 0.0], [1.75, 0.25, 0.25]),
|
||||
(0.0, 100.0, [140.0 / 255.0, 115.0 / 255.0, 128.0 / 255.0], [6.77432, -5.77432, 0.750976]),
|
||||
(0.0, 100.0, [2.0, 0.0, 0.0], [128.996, -126.996, -126.996]),
|
||||
] {
|
||||
let adj = Adjustment::HueSaturation { hue, saturation: sat, lightness: 0.0, colorize: false, ranges: HueRange::defaults() };
|
||||
let got = hdr(&adj, c);
|
||||
assert!(near(got, want), "hue {hue}, saturation {sat}, {c:?}: {got:?}, Photoshop {want:?}");
|
||||
}
|
||||
// A master plus a range edit that add up to a whole step stay on it: Reds +96 over a master
|
||||
// -46 scales red as much as a master +50 alone (in f32 the sum is 0.49999997).
|
||||
let mut ranges = HueRange::defaults();
|
||||
ranges[0].saturation = 96.0;
|
||||
let summed = Adjustment::HueSaturation { hue: 0.0, saturation: -46.0, lightness: 0.0, colorize: false, ranges };
|
||||
let single = Adjustment::HueSaturation { hue: 0.0, saturation: 50.0, lightness: 0.0, colorize: false, ranges: HueRange::defaults() };
|
||||
assert_eq!(hdr(&summed, [1.0, 0.0, 0.0]), hdr(&single, [1.0, 0.0, 0.0]));
|
||||
// Lightness 0 with a negative channel divides by zero the same way.
|
||||
let adj = Adjustment::HueSaturation { hue: 40.0, saturation: 25.0, lightness: 0.0, colorize: false, ranges: HueRange::defaults() };
|
||||
assert!(hdr(&adj, [0.25, -0.25, 0.0]).iter().all(|v| v.is_finite()));
|
||||
}
|
||||
|
||||
/// Photoshop's own 32-bit results (photoshop corpus rgb32): nothing clips, legacy contrast
|
||||
/// pivots at 127, the mixer cuts its percentages to 1/1024 and counts 255/512 of the
|
||||
/// constant, and the Black & White tint keeps values above 1.
|
||||
@@ -1736,6 +1911,66 @@ mod tone_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn color_balance_curve_matches_the_per_sample_formula_bitwise() {
|
||||
// The curve works out its sample-independent terms once; this is the formula it replaced,
|
||||
// evaluated whole for every sample.
|
||||
fn per_sample(tones: [[f32; 3]; 3], preserve_luminosity: bool, ch: usize, v: f32) -> f32 {
|
||||
let [shadows, midtones, highlights] = tones.map(|t| t.map(|s| if s.is_finite() { s.clamp(-100.0, 100.0) } else { 0.0 }));
|
||||
let own = |t: [f32; 3]| t.get(ch).copied().unwrap_or(0.0);
|
||||
let max = |t: [f32; 3]| t[0].max(t[1]).max(t[2]);
|
||||
let min = |t: [f32; 3]| t[0].min(t[1]).min(t[2]);
|
||||
let (black, white, m) = if preserve_luminosity {
|
||||
(max(shadows) - own(shadows), own(highlights) - min(highlights), own(midtones) - (max(midtones) + min(midtones)) / 2.0)
|
||||
} else {
|
||||
((-own(shadows)).max(0.0), own(highlights).max(0.0), own(midtones) + (own(shadows) + own(highlights)) / 2.0)
|
||||
};
|
||||
let (b, w) = (black / 255.0, 1.0 - white / 255.0);
|
||||
let v = if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 };
|
||||
if v >= w {
|
||||
return 1.0;
|
||||
}
|
||||
if v <= b {
|
||||
return 0.0;
|
||||
}
|
||||
let (u, gamma) = ((v - b) / (w - b), 2f32.powf(m / 100.0));
|
||||
let e = 1.0 / gamma.max(0.01);
|
||||
let power = u.powf(e);
|
||||
if gamma <= 1.0 {
|
||||
return power;
|
||||
}
|
||||
let s0 = 2f32.powf(gamma);
|
||||
let u0 = (2.0 * s0.powf(1.0 / (e - 1.0))).min(1.0);
|
||||
if u0.is_nan() || u0 <= 0.0 || u >= u0 {
|
||||
return power;
|
||||
}
|
||||
let (p, d, t) = (u0.powf(e), e * u0.powf(e - 1.0), u / u0);
|
||||
let (t2, t3) = (t * t, t * t * t);
|
||||
((t3 - 2.0 * t2 + t) * u0 * s0 + (3.0 * t2 - 2.0 * t3) * p + (t3 - t2) * u0 * d).clamp(0.0, 1.0)
|
||||
}
|
||||
let mut seed = 0x9e37_79b9_u32;
|
||||
let mut slider = || {
|
||||
seed ^= seed << 13;
|
||||
seed ^= seed >> 17;
|
||||
seed ^= seed << 5;
|
||||
(seed % 241) as f32 - 120.0
|
||||
};
|
||||
let mut settings = vec![[[0.0; 3]; 3], [[f32::NAN, 1e9, -1e9]; 3], [[-100.0; 3], [100.0; 3], [100.0; 3]]];
|
||||
settings.extend((0..120).map(|_| std::array::from_fn(|_| std::array::from_fn(|_| slider()))));
|
||||
let samples: Vec<f32> = (0..=1024).map(|i| i as f32 / 1024.0).chain([-0.5, 1.5, f32::NAN, f32::INFINITY]).collect();
|
||||
for tones in settings {
|
||||
for preserve in [false, true] {
|
||||
for ch in [0, 1, 2, 7] {
|
||||
let curve = BalanceCurve::new(tones, preserve, ch);
|
||||
for &v in &samples {
|
||||
let (got, want) = (curve.eval(v), per_sample(tones, preserve, ch, v));
|
||||
assert_eq!(got.to_bits(), want.to_bits(), "{tones:?} {preserve} ch {ch} v {v}: {got} vs {want}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn levels_and_curves_apply_each_channel_before_the_master() {
|
||||
// These two affine tone adjustments describe the same mapping:
|
||||
|
||||
@@ -266,6 +266,56 @@ fn interior_effects_blend_over_the_layer_mode_unless_grouped() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interior_effects_blend_with_the_backdrop_through_a_see_through_normal_layer() {
|
||||
// #6232: #04170F under a Normal layer at fill 0 with a yellow Soft Light colour overlay. The
|
||||
// overlay soft-lights the dark green it sees through the layer (Photoshop's soft light of
|
||||
// yellow: sqrt(b) for red and green, b² for blue), not transparency, which would leave plain
|
||||
// yellow. A half-transparent part of the layer mixes it in at half strength.
|
||||
let bg: [f32; 3] = [4.0 / 255.0, 23.0 / 255.0, 15.0 / 255.0];
|
||||
let lit = [bg[0].sqrt(), bg[1].sqrt(), bg[2] * bg[2]];
|
||||
let half = |c: usize| (bg[c] + lit[c]) / 2.0;
|
||||
for depth in DEPTHS {
|
||||
let mut d = doc(depth, false);
|
||||
d.layers.push(solid(&d, "green", full(&d), [bg[0], bg[1], bg[2], 1.0]));
|
||||
let mut l = solid(&d, "fade", Rect::new(0, 0, 6, 8), [0.2, 0.6, 0.9, 1.0]);
|
||||
l.surface_mut().unwrap().fill_rect(Rect::new(6, 0, 12, 8), &photocraft_raster::from_rgba(&d.pixel_format(), [0.2, 0.6, 0.9, 0.5]));
|
||||
l.fill_opacity = 0.0;
|
||||
l.effects.items = vec![Effect::ColorOverlay { common: FxCommon::new(BlendMode::SoftLight, 1.0), color: Color::rgb(1.0, 1.0, 0.0) }];
|
||||
d.layers.push(l);
|
||||
check(&d, 2, 2, [lit[0], lit[1], lit[2], 1.0], "opaque part");
|
||||
check(&d, 9, 2, [half(0), half(1), half(2), 1.0], "half-transparent part");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_see_through_normal_layer_without_interior_effects_counts_the_backdrop_once() {
|
||||
// A half-transparent backdrop under a Normal layer at fill 50 % whose only effect is a drop
|
||||
// shadow (cast off the canvas): plain Normal compositing, blue at 50 % over red at 50 %. The
|
||||
// layer must not merge into the backdrop first, or the backdrop shows twice (alpha 0.875).
|
||||
for depth in DEPTHS {
|
||||
let mut d = doc(depth, false);
|
||||
d.layers.push(solid(&d, "red", full(&d), [1.0, 0.0, 0.0, 0.5]));
|
||||
let mut l = solid(&d, "blue", Rect::new(0, 0, 12, 4), BLUE);
|
||||
l.fill_opacity = 0.5;
|
||||
l.effects.items = vec![Effect::DropShadow(photocraft_doc::Shadow {
|
||||
common: FxCommon::new(BlendMode::Normal, 1.0),
|
||||
color: Color::BLACK,
|
||||
angle: 90.0,
|
||||
use_global_light: false,
|
||||
distance: 20.0,
|
||||
spread: 1.0,
|
||||
size: 0.0,
|
||||
contour: photocraft_doc::Contour::Linear,
|
||||
anti_alias: false,
|
||||
noise: 0.0,
|
||||
knocks_out: true,
|
||||
})];
|
||||
d.layers.push(l);
|
||||
check(&d, 2, 2, [1.0 / 3.0, 0.0, 2.0 / 3.0, 0.75], "inside the layer");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transparency_shapes_off_spreads_effects_over_the_whole_layer() {
|
||||
for depth in DEPTHS {
|
||||
|
||||
@@ -87,13 +87,18 @@ pub const MAX_REACH: f32 = 512.0;
|
||||
/// shape's extent), Precise glows and chiselled bevels (exact Euclidean distances, whose f32
|
||||
/// arithmetic depends on where the region starts) and noise (it speckles any coverage above
|
||||
/// zero, so it would pick up the blur's float noise, which depends on where the region starts).
|
||||
/// Dissolve is not local for the same reason: it turns any coverage above zero into a chance of
|
||||
/// a full pixel.
|
||||
pub(crate) fn maps_are_local(layer: &Layer) -> bool {
|
||||
layer.effects.items.iter().filter(|e| e.enabled()).all(|e| match e {
|
||||
Effect::Stroke(s) => !matches!(s.paint, FxPaint::Gradient(_)),
|
||||
Effect::DropShadow(s) | Effect::InnerShadow(s) => s.noise <= 0.0,
|
||||
Effect::OuterGlow(g) | Effect::InnerGlow(g) => g.technique != GlowTechnique::Precise && g.noise <= 0.0,
|
||||
Effect::BevelEmboss(b) => b.technique == BevelTechnique::Smooth,
|
||||
_ => true,
|
||||
layer.effects.items.iter().filter(|e| e.enabled()).all(|e| {
|
||||
!dissolves(e)
|
||||
&& match e {
|
||||
Effect::Stroke(s) => !matches!(s.paint, FxPaint::Gradient(_)),
|
||||
Effect::DropShadow(s) | Effect::InnerShadow(s) => s.noise <= 0.0,
|
||||
Effect::OuterGlow(g) | Effect::InnerGlow(g) => g.technique != GlowTechnique::Precise && g.noise <= 0.0,
|
||||
Effect::BevelEmboss(b) => b.technique == BevelTechnique::Smooth,
|
||||
_ => true,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1019,12 +1024,7 @@ fn noise(m: &mut Map, amount: f32, x0: i32, y0: i32) {
|
||||
fn apply_contour(m: Map, c: &Contour) -> Map {
|
||||
match contour_lut(c) {
|
||||
None => m,
|
||||
Some(l) => m.map(|v| {
|
||||
let x = v.clamp(0.0, 1.0) * (l.len() - 1) as f32;
|
||||
let i = x.floor() as usize;
|
||||
let j = (i + 1).min(l.len() - 1);
|
||||
l[i] + (l[j] - l[i]) * (x - i as f32)
|
||||
}),
|
||||
Some(l) => m.map(|v| lut_at(&l, v)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1129,16 +1129,43 @@ fn sample_stops(stops: &[(f32, [f32; 3])], t: f32) -> [f32; 3] {
|
||||
|
||||
/// Composite `color × coverage` into `dst` (both over the same rectangle).
|
||||
fn paint(dst: &mut Buffer, m: &Map, color: impl Fn(usize) -> [f32; 4], blend: BlendMode, opacity: f32) {
|
||||
let (x0, y0, w) = (i64::from(dst.rect.x0), i64::from(dst.rect.y0), (dst.rect.width().max(1)) as usize);
|
||||
for (i, p) in dst.px.iter_mut().enumerate() {
|
||||
let a = m.v[i] * opacity;
|
||||
if a <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let c = color(i);
|
||||
if blend == BlendMode::Dissolve {
|
||||
if dissolves_in(x0 + (i % w) as i64, y0 + (i / w) as i64, c[3] * a) {
|
||||
*p = psblend::composite(BlendMode::Normal, *p, [c[0], c[1], c[2], 1.0], 1.0);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
*p = psblend::composite(blend, *p, [c[0], c[1], c[2], c[3] * a], 1.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `e` paints anything in Dissolve mode.
|
||||
fn dissolves(e: &Effect) -> bool {
|
||||
let d = |m: BlendMode| m == BlendMode::Dissolve;
|
||||
match e {
|
||||
Effect::DropShadow(s) | Effect::InnerShadow(s) => d(s.common.blend),
|
||||
Effect::OuterGlow(g) | Effect::InnerGlow(g) => d(g.common.blend),
|
||||
Effect::Stroke(s) => d(s.common.blend),
|
||||
Effect::ColorOverlay { common, .. } | Effect::GradientOverlay { common, .. } | Effect::PatternOverlay { common, .. } => d(common.blend),
|
||||
Effect::Satin(s) => d(s.common.blend),
|
||||
Effect::BevelEmboss(b) => d(b.highlight.blend) || d(b.shadow.blend),
|
||||
}
|
||||
}
|
||||
|
||||
/// Dissolve on an effect (#6995): a pixel takes the effect fully or not at all, with probability
|
||||
/// `coverage`, picked by the same document-coordinate noise as a Dissolve layer. A soft glow or
|
||||
/// shadow turns into scattered speckle instead of a smooth falloff.
|
||||
fn dissolves_in(x: i64, y: i64, coverage: f32) -> bool {
|
||||
hash_noise(x, y) < coverage
|
||||
}
|
||||
|
||||
fn paint_color(dst: &mut Buffer, m: &Map, c: [f32; 3], blend: BlendMode, opacity: f32) {
|
||||
paint(dst, m, |_| [c[0], c[1], c[2], 1.0], blend, opacity);
|
||||
}
|
||||
@@ -1350,11 +1377,23 @@ fn box_line(src: &[f64], x0: i64, dst: &mut [f64], bw: f32) {
|
||||
}
|
||||
return;
|
||||
}
|
||||
let mut inner: f64 = (x0 - (r - 1)..=x0 + (r - 1)).map(at).sum();
|
||||
let window = x0 - (r - 1)..=x0 + (r - 1);
|
||||
let mut inner: f64 = window.clone().map(at).sum();
|
||||
let mut last_covered = window.rev().find(|&x| at(x) != 0.0).unwrap_or(i64::MIN);
|
||||
for (k, d) in dst.iter_mut().enumerate() {
|
||||
let x = x0 + k as i64;
|
||||
*d = (inner + f * (at(x - r) + at(x + r))) * norm;
|
||||
inner += at(x + r) - at(x - r + 1);
|
||||
let entering = at(x + r);
|
||||
inner += entering - at(x - r + 1);
|
||||
if entering != 0.0 {
|
||||
last_covered = x + r;
|
||||
}
|
||||
// Cancellation can leave positive residue after the last covered pixel leaves.
|
||||
// Noise amplifies even that residue into visible speckles (#5638). Reset only
|
||||
// truly empty windows, preserving arbitrarily faint real coverage.
|
||||
if last_covered < x - r + 2 {
|
||||
inner = 0.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1656,6 +1695,12 @@ fn bevel_maps(shape: &Map, b: &Bevel, light: &GlobalLight, tex: &TextureCtx, pat
|
||||
let on_bevel = |x: i64, y: i64| {
|
||||
hmap.get(x, y) > BEVEL_H_EPS || (reach_past && [(1, 0), (-1, 0), (0, 1), (0, -1)].iter().any(|(dx, dy)| hmap.get(x + dx, y + dy) > BEVEL_H_EPS))
|
||||
};
|
||||
// The gloss contour remaps the shading `n · light` before it splits into highlight (above
|
||||
// what it maps a flat face's `sin(altitude)` to) and shadow. Applied to the highlight and shadow amounts
|
||||
// instead, a contour that starts high (Rolling Slope - Descending) lit every pixel the light
|
||||
// missed, so a bevelled glyph's flat face took the full highlight (#7360) where Photoshop
|
||||
// leaves it at the layer's colour. The Linear contour changes nothing either way.
|
||||
let gloss = contour_lut(&b.gloss_contour);
|
||||
let shade_into = |depth: f32, region_kind: u8| -> (Map, Map) {
|
||||
let (mut hi, mut sh) = (Map::new(shape.w, shape.h, 0.0), Map::new(shape.w, shape.h, 0.0));
|
||||
// Rows on threads: every pixel reads only `hmap` and `shape`.
|
||||
@@ -1676,7 +1721,7 @@ fn bevel_maps(shape: &Map, b: &Bevel, light: &GlobalLight, tex: &TextureCtx, pat
|
||||
let len = (n[0] * n[0] + n[1] * n[1] + 1.0).sqrt();
|
||||
let shade = (n[0] * light_v[0] + n[1] * light_v[1] + n[2] * light_v[2]) / len;
|
||||
let region = if region_kind == 0 { shape.v[i] } else { f32::from(shape.v[i] < 1.0 - INSIDE_EPS && on_bevel(x, y)) };
|
||||
let k = shade - se;
|
||||
let k = gloss.as_ref().map_or(shade - se, |l| lut_at(l, shade) - lut_at(l, se));
|
||||
if k > 0.0 {
|
||||
hi_row[x as usize] = (k / (1.0 - se).max(1e-3)).clamp(0.0, 1.0) * region;
|
||||
} else {
|
||||
@@ -1685,7 +1730,7 @@ fn bevel_maps(shape: &Map, b: &Bevel, light: &GlobalLight, tex: &TextureCtx, pat
|
||||
}
|
||||
},
|
||||
);
|
||||
(apply_contour(hi, &b.gloss_contour), apply_contour(sh, &b.gloss_contour))
|
||||
(hi, sh)
|
||||
};
|
||||
let maps = match g.paint {
|
||||
BevelPaint::Inner | BevelPaint::Outer => {
|
||||
@@ -1814,6 +1859,29 @@ pub fn build_maps(layer: &Layer, shape: Vec<f32>, rect: Rect, light: &GlobalLigh
|
||||
build_maps_prepared(layer, shape, rect, light, tex, &prepared)
|
||||
}
|
||||
|
||||
/// How many region-sized arrays [`build_maps_prepared`] builds for `layer` (what
|
||||
/// [`FxMaps::bytes`] counts): the shape, each effect's maps, and a stroke's distance fields. Kept
|
||||
/// next to the builder so the effect cache's estimate (#8330) follows it.
|
||||
pub(crate) fn map_count(layer: &Layer) -> usize {
|
||||
let items: Vec<&Effect> = layer.effects.items.iter().filter(|e| e.enabled()).collect();
|
||||
let per: usize = items
|
||||
.iter()
|
||||
.map(|e| match e {
|
||||
Effect::DropShadow(_) | Effect::InnerShadow(_) | Effect::OuterGlow(_) | Effect::InnerGlow(_) | Effect::Satin(_) => 1,
|
||||
Effect::BevelEmboss(_) => 2,
|
||||
_ => 0,
|
||||
})
|
||||
.sum();
|
||||
let out_widths: Vec<f32> =
|
||||
items.iter().filter_map(|e| if let Effect::Stroke(st) = e { Some(stroke_widths(st).1) } else { None }).filter(|w| *w > 0.0).collect();
|
||||
let has_stroke = items.iter().any(|e| matches!(e, Effect::Stroke(_)));
|
||||
let outside = usize::from(out_widths.iter().any(|&w| !stroke_outside_exact(w))) + usize::from(out_widths.iter().any(|&w| stroke_outside_exact(w)));
|
||||
let stroke = if has_stroke { 1 + outside } else { 0 };
|
||||
let vector_outline =
|
||||
if has_stroke && matches!(layer.content, photocraft_doc::LayerContent::Shape(_)) && crate::effect_outline(layer).is_none() { outside } else { 0 };
|
||||
1 + per + stroke + vector_outline
|
||||
}
|
||||
|
||||
pub(crate) fn build_maps_prepared(
|
||||
layer: &Layer,
|
||||
shape: Vec<f32>,
|
||||
@@ -1913,9 +1981,33 @@ pub fn shadow_see_through(layer: &Layer) -> 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.
|
||||
/// tints the grey beneath it (#3996). A Normal layer merges only when an interior effect blends
|
||||
/// in another mode: below 100 % fill that effect sees the backdrop through the layer, so a Soft
|
||||
/// Light overlay on a Fill 0 % layer soft-lights the backdrop instead of showing its own colour
|
||||
/// (#6232). Normal effects give the same result either way, and the merge would count a
|
||||
/// half-transparent backdrop twice, so those layers keep the plain composite.
|
||||
pub fn premerges_layer(layer: &Layer) -> bool {
|
||||
!layer.advanced.blend_interior && !matches!(layer.blend, BlendMode::Normal | BlendMode::PassThrough | BlendMode::Dissolve)
|
||||
!layer.advanced.blend_interior
|
||||
&& match layer.blend {
|
||||
BlendMode::PassThrough | BlendMode::Dissolve => false,
|
||||
BlendMode::Normal => blends_inside(layer),
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// `true` if an enabled effect painted inside the layer (with its content) blends in a mode
|
||||
/// other than Normal.
|
||||
fn blends_inside(layer: &Layer) -> bool {
|
||||
let blends = |m: BlendMode| m != BlendMode::Normal;
|
||||
layer.effects.items.iter().filter(|e| e.enabled()).any(|e| match e {
|
||||
Effect::ColorOverlay { common, .. } | Effect::GradientOverlay { common, .. } | Effect::PatternOverlay { common, .. } => blends(common.blend),
|
||||
Effect::Satin(s) => blends(s.common.blend),
|
||||
Effect::InnerGlow(g) => blends(g.common.blend),
|
||||
Effect::InnerShadow(s) => blends(s.common.blend),
|
||||
Effect::Stroke(s) => stroke_widths(s).0 > 0.0 && blends(s.common.blend),
|
||||
Effect::BevelEmboss(b) => bevel_geom(b).paint == BevelPaint::Inner && (blends(b.highlight.blend) || blends(b.shadow.blend)),
|
||||
Effect::DropShadow(_) | Effect::OuterGlow(_) => false,
|
||||
})
|
||||
}
|
||||
|
||||
/// Composites `content` (the layer's own pixels over `big`, alpha already
|
||||
@@ -2159,13 +2251,20 @@ pub(crate) fn composite_with_effects_prepared(
|
||||
} else {
|
||||
band
|
||||
};
|
||||
*sh = k * st.common.opacity * (1.0 - cover[i]);
|
||||
let mut k = k * st.common.opacity;
|
||||
if st.common.blend == BlendMode::Dissolve {
|
||||
let (x, y) = (i64::from(big.x0) + (i % w) as i64, i64::from(big.y0) + (i / w) as i64);
|
||||
k = if k > 0.0 && dissolves_in(x, y, k) { 1.0 } else { 0.0 };
|
||||
}
|
||||
*sh = k * (1.0 - cover[i]);
|
||||
cover[i] += *sh;
|
||||
}
|
||||
// The stroke blended over the backdrop at full coverage.
|
||||
let mut blended = base.clone();
|
||||
let ones = Map { w, h, v: share.iter().map(|&s| if s > 0.0 { 1.0 } else { 0.0 }).collect() };
|
||||
paint_fx(&mut blended, &ones, &st.paint, frame(st), anchor, big, st.common.blend, 1.0, patterns);
|
||||
// The share already carries the Dissolve choice, so the paint itself composites as Normal.
|
||||
let paint_blend = if st.common.blend == BlendMode::Dissolve { BlendMode::Normal } else { st.common.blend };
|
||||
paint_fx(&mut blended, &ones, &st.paint, frame(st), anchor, big, paint_blend, 1.0, patterns);
|
||||
for ((a, b), s) in acc.iter_mut().zip(&blended.px).zip(&share) {
|
||||
if *s > 0.0 {
|
||||
for c in 0..3 {
|
||||
@@ -2221,7 +2320,13 @@ pub(crate) fn composite_with_effects_prepared(
|
||||
let i = ((y - rect.y0) as usize) * rect.width() as usize + (x - rect.x0) as usize;
|
||||
let a = before.px[i];
|
||||
let b = if x >= big.x0 && x < big.x1 && y >= big.y0 && y < big.y1 { work.px[((y - big.y0) as usize) * w + (x - big.x0) as usize] } else { a };
|
||||
backdrop.px[i] = mix_premul(a, b, op);
|
||||
backdrop.px[i] = if layer.blend == BlendMode::Dissolve {
|
||||
// Dissolve applies layer opacity to the completed interior/exterior effect
|
||||
// stack as pixel coverage, not continuous gray interpolation (#3051).
|
||||
if crate::dissolve_noise(x, y) < op { b } else { a }
|
||||
} else {
|
||||
mix_premul(a, b, op)
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3304,6 +3409,37 @@ mod tests {
|
||||
assert!(heavy.v.iter().any(|v| *v < 0.5) && heavy.v.iter().any(|v| *v > 0.5), "heavy noise spans the range",);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn box_blur_preserves_faint_coverage_and_empty_gaps() {
|
||||
let src = [0.0, 0.3, 0.7, 0.0, 0.0, 0.0, 0.0, 1e-20, 0.0, 0.0, 0.0];
|
||||
let mut actual = [0.0; 11];
|
||||
box_line(&src, 0, &mut actual, 3.0);
|
||||
assert_eq!(actual[5], 0.0, "an empty window must be exactly empty");
|
||||
assert_eq!(actual[10], 0.0, "the second tail must also be empty");
|
||||
assert!((actual[7] - 1e-20 / 3.0).abs() < 1e-30, "real faint coverage is not rounded away");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effect_noise_does_not_light_up_empty_blur_tails() {
|
||||
// A sloping antialiased shape leaves rounding residue in a running-sum blur.
|
||||
// The direct finite-support convolution is the oracle for untouched pixels.
|
||||
for size in [5.0, 5.5, 13.0] {
|
||||
let mut shape = Map::new(96, 64, 0.0);
|
||||
for y in 8..48 {
|
||||
for x in 8..48 {
|
||||
shape.v[y * shape.w + x] = ((x as f32 - y as f32 * 0.7) * 0.3).clamp(0.0, 1.0);
|
||||
}
|
||||
}
|
||||
let mut direct = shape.clone();
|
||||
tent_direct(&mut direct, size);
|
||||
let mut actual = shape;
|
||||
blur(&mut actual, size);
|
||||
noise(&mut actual, 0.26, 0, 0);
|
||||
let stray = direct.v.iter().zip(&actual.v).filter(|(reference, noisy)| **reference == 0.0 && **noisy > 1e-5).count();
|
||||
assert_eq!(stray, 0, "noise created {stray} visible pixels outside a size-{size} blur");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn glow_noise_follows_the_contour_and_range() {
|
||||
let g = |noise: f32| Glow {
|
||||
|
||||
@@ -399,6 +399,7 @@ pub fn render_layer(layer: &Layer, rect: Rect) -> Buffer {
|
||||
vector_masks: RenderVectorMasks::default(),
|
||||
fx_maps: Default::default(),
|
||||
fx_clip: Default::default(),
|
||||
fx_over_budget: false,
|
||||
tone_luts: prepare_tones(std::slice::from_ref(layer), 0),
|
||||
doc: None,
|
||||
layer_keys: Default::default(),
|
||||
@@ -771,6 +772,9 @@ struct Ctx<'a> {
|
||||
fx_maps: std::sync::Mutex<std::collections::HashMap<u64, (std::sync::Arc<effects::FxMaps>, bool)>>,
|
||||
/// The rectangle being rendered (a band of [`render_bands`]); see [`effect_region`].
|
||||
fx_clip: std::sync::Mutex<Option<Rect>>,
|
||||
/// The whole-region effect maps of this render's layers wouldn't fit in the effect cache
|
||||
/// together ([`fx_over_budget`]), so local ones are clipped to the render rect (#8330).
|
||||
fx_over_budget: bool,
|
||||
/// The document being rendered (deep knockouts reveal its Background); `None` for a lone layer.
|
||||
doc: Option<&'a Document>,
|
||||
/// Effect-map identities of this render's document layers (see [`LayerKeys`]).
|
||||
@@ -842,6 +846,7 @@ impl<'a> Ctx<'a> {
|
||||
vector_masks: RenderVectorMasks::default(),
|
||||
fx_maps: Default::default(),
|
||||
fx_clip: Default::default(),
|
||||
fx_over_budget: fx_over_budget(&doc.layers, doc.bounds()),
|
||||
layer_keys: Default::default(),
|
||||
tone_luts: prepare_tones(&doc.layers, 0),
|
||||
}
|
||||
@@ -984,11 +989,41 @@ pub fn adjustment_quantum(depth: photocraft_color::SampleType) -> Option<f32> {
|
||||
fn quantize(b: &mut Buffer, q: f32) {
|
||||
for p in &mut b.px {
|
||||
for v in p.iter_mut() {
|
||||
*v = (*v * q + 0.5).floor() / q;
|
||||
*v = branchless_floor(*v * q + 0.5) / q;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `x.floor()`, bit for bit (signed zeros, infinities and NaNs included), without a call: baseline
|
||||
/// x86-64 has no SSE4.1 `roundss`, so `floor` is a software routine there, called once per sample
|
||||
/// of the backdrop under every adjustment layer ([`quantize`]). This form has no branch, so the
|
||||
/// loop vectorizes.
|
||||
#[inline]
|
||||
fn branchless_floor(x: f32) -> f32 {
|
||||
// From 2^23 on every f32 is whole. Below it, adding and taking away 2^23 rounds to the
|
||||
// nearest whole number; where that went up, step down.
|
||||
const WHOLE: f32 = 8_388_608.0;
|
||||
let a = x.abs();
|
||||
let r = ((a + WHOLE) - WHOLE).copysign(x);
|
||||
let r = if r > x { r - 1.0 } else { r };
|
||||
if a < WHOLE { r } else { x }
|
||||
}
|
||||
|
||||
/// Blend an adjustment layer's result (`adjusted`) back into the backdrop it was made from, each
|
||||
/// pixel by its coverage `ks` (opacity × mask) in blend mode `mode`.
|
||||
fn blend_back(backdrop: &mut [[f32; 4]], adjusted: &[[f32; 4]], ks: impl Iterator<Item = f32>, mode: BlendMode) {
|
||||
for ((b, a), k) in backdrop.iter_mut().zip(adjusted).zip(ks) {
|
||||
if k <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let s = [a[0], a[1], a[2]];
|
||||
// Normal is the source as it is (`blend_rgb` gives exactly that), and most adjustment
|
||||
// layers are Normal: no per-pixel mode dispatch for them.
|
||||
let blended = if mode == BlendMode::Normal { s } else { blend::blend_rgb(mode, [b[0], b[1], b[2]], s) };
|
||||
*b = [b[0] + (blended[0] - b[0]) * k, b[1] + (blended[1] - b[1]) * k, b[2] + (blended[2] - b[2]) * k, b[3]];
|
||||
}
|
||||
}
|
||||
|
||||
/// Deterministic hash for Dissolve (document-coordinate based, so it is stable under tiling).
|
||||
#[inline]
|
||||
fn dissolve_noise(x: i32, y: i32) -> f32 {
|
||||
@@ -1890,18 +1925,23 @@ fn composite_layer_plain(layer: &Layer, clipped: &[Layer], backdrop: &mut Buffer
|
||||
cx.apply_adjustment(adj, &mut adjusted);
|
||||
// Clipped layers onto an adjustment are uncommon; they composite atop the adjusted result.
|
||||
advanced::composite_clipped(clipped, &mut adjusted, cx);
|
||||
for y in rect.y0..rect.y1 {
|
||||
for x in rect.x0..rect.x1 {
|
||||
let i = ((y - rect.y0) as usize) * rect.width() as usize + (x - rect.x0) as usize;
|
||||
let k = opacity * mask_k(&mv, i);
|
||||
if k <= 0.0 {
|
||||
continue;
|
||||
if layer.blend == BlendMode::Dissolve {
|
||||
// An adjustment has no standalone source alpha. Its effective coverage comes from its
|
||||
// own opacity and mask, not a continuous RGB mix (#3050).
|
||||
for y in rect.y0..rect.y1 {
|
||||
for x in rect.x0..rect.x1 {
|
||||
let i = ((y - rect.y0) as usize) * rect.width() as usize + (x - rect.x0) as usize;
|
||||
let k = opacity * mask_k(&mv, i);
|
||||
if k > 0.0 && dissolve_noise(x, y) < k {
|
||||
let a = adjusted.px[i];
|
||||
backdrop.px[i] = [a[0], a[1], a[2], backdrop.px[i][3]];
|
||||
}
|
||||
}
|
||||
// Only `adjusted` has changed; this pixel's original backdrop is still in place.
|
||||
let b = backdrop.px[i];
|
||||
let a = adjusted.px[i];
|
||||
let blended = blend::blend_rgb(layer.blend, [b[0], b[1], b[2]], [a[0], a[1], a[2]]);
|
||||
backdrop.px[i] = [b[0] + (blended[0] - b[0]) * k, b[1] + (blended[1] - b[1]) * k, b[2] + (blended[2] - b[2]) * k, b[3]];
|
||||
}
|
||||
} else {
|
||||
match &mv {
|
||||
Some(m) => blend_back(&mut backdrop.px, &adjusted.px, m.iter().map(|v| opacity * v), layer.blend),
|
||||
None => blend_back(&mut backdrop.px, &adjusted.px, std::iter::repeat(opacity), layer.blend),
|
||||
}
|
||||
}
|
||||
recycle_buffer(adjusted);
|
||||
@@ -2136,7 +2176,9 @@ fn fx_cache() -> &'static std::sync::Mutex<FxCache> {
|
||||
/// 1920 × 103675 webtoon page each such layer's maps took about 2.4 GB, and a render held them for
|
||||
/// every layer at once (60 GB with 25 stroked type layers, #1909). When its effects are local
|
||||
/// ([`effects::maps_are_local`]), such a layer's maps are built over the render rectangle padded
|
||||
/// by twice the effect reach instead, which gives the same pixels there. Only when `rect`, what
|
||||
/// by twice the effect reach instead, which gives the same pixels there. So are any layer's local
|
||||
/// maps when the render's whole-region maps wouldn't fit in the effect cache ([`fx_over_budget`]).
|
||||
/// Only when `rect`, what
|
||||
/// the caller composites the layer over, lies within the render rectangle: a group's effect shape
|
||||
/// (or anything else rendering children over a wider area) gets the whole region.
|
||||
fn effect_region(layer: &Layer, rect: Rect, cx: &Ctx) -> (Rect, bool) {
|
||||
@@ -2144,7 +2186,7 @@ fn effect_region(layer: &Layer, rect: Rect, cx: &Ctx) -> (Rect, bool) {
|
||||
let region = effect_source_bounds(layer, cx.canvas).inflate(m).intersect(&cx.canvas.inflate(m));
|
||||
let clip = *cx.fx_clip.lock().unwrap_or_else(|e| e.into_inner());
|
||||
match clip {
|
||||
Some(c) if c.contains_rect(&rect) && advanced::shapeless(layer) && effects::maps_are_local(layer) => {
|
||||
Some(c) if c.contains_rect(&rect) && (advanced::shapeless(layer) || cx.fx_over_budget) && effects::maps_are_local(layer) => {
|
||||
let part = region.intersect(&c.inflate(m.saturating_mul(2)));
|
||||
(part, part != region)
|
||||
}
|
||||
@@ -2152,6 +2194,38 @@ fn effect_region(layer: &Layer, rect: Rect, cx: &Ctx) -> (Rect, bool) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the effect maps of the visible layers of `layers` (groups included), each over its
|
||||
/// whole region, would take more than the effect cache budget together. A render needs all of
|
||||
/// them, so when they don't fit, building one evicts the next one needed and every render builds
|
||||
/// them all again, seconds even for a small rect (#8330). Counted as [`effects::FxMaps::bytes`]
|
||||
/// counts the built maps ([`effects::map_count`] arrays over each region).
|
||||
fn fx_over_budget(layers: &[Layer], canvas: Rect) -> bool {
|
||||
fx_region_bytes(layers, canvas) > effect_cache_budget() as u64
|
||||
}
|
||||
|
||||
/// The bytes [`fx_over_budget`] compares with the budget.
|
||||
fn fx_region_bytes(layers: &[Layer], canvas: Rect) -> u64 {
|
||||
fn bytes(layers: &[Layer], canvas: Rect, depth: u32) -> u64 {
|
||||
let mut total = 0u64;
|
||||
for l in layers.iter().filter(|l| l.visible) {
|
||||
if effects::has_effects(l) {
|
||||
let m = effects::margin(l);
|
||||
let region = effect_source_bounds(l, canvas).inflate(m).intersect(&canvas.inflate(m));
|
||||
let maps = effects::map_count(l) as u64;
|
||||
let px = u64::from(region.width()) * u64::from(region.height());
|
||||
total = total.saturating_add(maps.saturating_mul(px).saturating_mul(4));
|
||||
}
|
||||
if let LayerContent::Group(g) = &l.content
|
||||
&& depth < PREPARE_DEPTH
|
||||
{
|
||||
total = total.saturating_add(bytes(&g.children, canvas, depth + 1));
|
||||
}
|
||||
}
|
||||
total
|
||||
}
|
||||
bytes(layers, canvas, 0)
|
||||
}
|
||||
|
||||
/// The layer's effect maps over its region (layer bounds grown by the effect reach, within the
|
||||
/// canvas grown likewise; see [`effect_region`]), built once per layer state and shared by every
|
||||
/// tile. `rect` is what the caller composites the layer over (see [`effect_region`]).
|
||||
@@ -2265,6 +2339,14 @@ fn composite_atop_any(layer: &Layer, base: &mut Buffer, cx: &Ctx) {
|
||||
for (i, p) in base.px.iter_mut().enumerate() {
|
||||
let k = layer.opacity * layer.fill_opacity * mask_k(&mv, i);
|
||||
let a = adjusted.px[i];
|
||||
if layer.blend == BlendMode::Dissolve {
|
||||
let x = rect.x0 + (i % rect.width() as usize) as i32;
|
||||
let y = rect.y0 + (i / rect.width() as usize) as i32;
|
||||
if dissolve_noise(x, y) < k {
|
||||
*p = [a[0], a[1], a[2], p[3]];
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let bl = blend::blend_rgb(layer.blend, [p[0], p[1], p[2]], [a[0], a[1], a[2]]);
|
||||
for c in 0..3 {
|
||||
p[c] += (bl[c] - p[c]) * k;
|
||||
@@ -2311,6 +2393,16 @@ fn composite_atop_any(layer: &Layer, base: &mut Buffer, cx: &Ctx) {
|
||||
continue;
|
||||
}
|
||||
let s = content.px[i];
|
||||
if layer.blend == BlendMode::Dissolve {
|
||||
// Clipping preserves the opaque base's alpha. Dissolve still selects source
|
||||
// pixels by spatial coverage instead of continuously mixing the colours (#3052).
|
||||
let x = rect.x0 + (i as i32 % rect.width() as i32);
|
||||
let y = rect.y0 + (i as i32 / rect.width() as i32);
|
||||
if dissolve_noise(x, y) < s[3] * opacity {
|
||||
*p = [s[0], s[1], s[2], alpha];
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Blend as if the base were opaque, then keep the base's alpha.
|
||||
let r = blend::composite_gamma(layer.blend, [p[0], p[1], p[2], 1.0], s, opacity, gamma);
|
||||
*p = [r[0], r[1], r[2], alpha];
|
||||
|
||||