mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-19 02:10:49 -05:00
* Open HEIC/HEIF photos (#355) iPhones save HEIC by default and Photoshop opens it; PhotoCraft had no HEIF code, so .heic was missing from File > Open and failed to decode. Add Format::Heif to photocraft-codecs, decoded by heic-rs (pure Rust, forbid(unsafe), MIT OR Apache-2.0, wasm-ready): single pictures and grid-tiled photos, 8-bit to U8 and 10/12-bit to U16, auxiliary alpha, ICC, EXIF and XMP. The container's irot/imir/clap are applied and EXIF Orientation is rewritten to 1, so photos are never turned twice. Limits are checked against the declared size before decoding. Read-only (no pure-Rust HEVC encoder), listed in ASYMMETRIC_EXCEPTIONS. File > Open (desktop, web) and the batch list accept heic/heif/hif. Tests compare against Apple's own decode and a 16-bit source, cover orientation, metadata, limits and every truncation; fixtures from heic-rs and pillow-heif are attributed in ATTRIBUTION.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * HEIF: pin heic-rs exactly and add a decode_heif fuzz target Review follow-up: heic-rs is young and parses untrusted files, so pin it (=0.1.1) and fuzz the HEIF path. The target covers both orientation paths and is meant to run with -timeout so a hang is reported, not only a panic. It is added to the CI fuzz matrix. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * HEIF: a heic-rs panic is a malformed-file error, not a crash Fuzzing decode_heif found two out-of-range slices in heic-rs 0.1.1 on malformed files: - in its box parser (boxes.rs:130), reachable from a 72-byte file, even through the metadata-only probe; - during HEVC reconstruction (hevc/decode/recon.rs:70). The background job path catches panics, but the synchronous ones (jobs off, the CLI) did not. The HEIF codec now runs the decoder under catch_unwind and reports a panic as CodecError::Malformed, as gpu/health.rs already does around wgpu. The fuzz target also calls heic-rs directly, so its panics still reach the fuzzer. The 72-byte input is a regression test, which fails without the guard. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * HEIF: move the decoder into an optional photocraft-heif crate The heic-rs decoding from #372 now lives in `crates/heif` (photocraft-heif, standalone, depends only on heic-rs =0.1.1): probe, decode with a pixel limit, container transforms, alpha, 8/10-bit, ICC/EXIF/XMP and the catch_unwind guard, behind a small API of its own (Info, Options, Decoded, Error). photocraft-codecs uses it behind a new non-default `heif` feature; the apps and photocraft-io forward it. HEIF detection, extensions and mime stay compiled in, so without the feature a HEIC file is still recognised and opening it is a clear "HEIC/HEIF support isn't included in this build of PhotoCraft" error. `cargo xtask layers` allows exactly codecs -> heif between standalone crates. The fixtures are no longer committed: `cargo xtask corpus --heif` fetches them from heic-rs (tests/fixtures, v0.1.1) and pillow-heif (tests/images) at pinned commits, verified against xtask/heif-corpus.sha256 (same bytes as the PR's copies). The fixture tests run with `--features corpus,heif` (`cargo xtask test-corpus` enables both). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Build CI and official binaries with HEIC support (--features heif) CI test and clippy, the FreeBSD job, and the macOS, Linux (and Flatpak), FreeBSD, Windows and web packaging all build with the `heif` feature; CI also tests codecs/io without it on Linux so the "not in this build" path stays covered. `cargo xtask wasm` also checks photocraft-codecs with `--features heif`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Docs: HEIC open is an optional build feature, on in official builds Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
43 files changed
+960
-50
No files matched your search
@@ -51,8 +51,15 @@ jobs:
|
||||
if: runner.os == 'Linux'
|
||||
run: cargo fmt --all -- --check
|
||||
|
||||
# `--features heif`: official builds open HEIC photos via the optional photocraft-heif crate.
|
||||
- name: Test
|
||||
run: cargo test --workspace --locked
|
||||
run: cargo test --workspace --locked --features heif
|
||||
|
||||
# The same codecs without the optional decoder: HEIC must stay a clear "not in this build"
|
||||
# error for distributors who leave it out.
|
||||
- name: Test without heif
|
||||
if: runner.os == 'Linux'
|
||||
run: cargo test -p photocraft-heif -p photocraft-codecs -p photocraft-io --locked
|
||||
|
||||
# The same tests built with the optional craft-fonts input (crates/text/build.rs), so the
|
||||
# Japanese font tests that skip without it run here. Linux only.
|
||||
@@ -69,10 +76,10 @@ jobs:
|
||||
env:
|
||||
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
|
||||
CRAFT_FONTS_REQUIRED: "1"
|
||||
run: cargo test --workspace --locked
|
||||
run: cargo test --workspace --locked --features heif
|
||||
|
||||
- name: Clippy
|
||||
run: cargo clippy --workspace --all-targets --locked -- -D warnings
|
||||
run: cargo clippy --workspace --all-targets --locked --features heif -- -D warnings
|
||||
|
||||
- name: Dependency layering
|
||||
run: cargo xtask layers
|
||||
@@ -136,6 +143,7 @@ jobs:
|
||||
- { crate: codecs, name: decode_jpeg }
|
||||
- { crate: codecs, name: decode_tiff }
|
||||
- { crate: codecs, name: decode_exr }
|
||||
- { crate: codecs, name: decode_heif }
|
||||
- { crate: format, name: load }
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -51,8 +51,8 @@ jobs:
|
||||
# The checkout is synced in under another owner; let tests that call git read it.
|
||||
git config --global --add safe.directory "*"
|
||||
rustc --version
|
||||
CARGO_PROFILE_DEV_DEBUG=0 cargo build --workspace --all-targets --locked
|
||||
CARGO_PROFILE_DEV_DEBUG=0 cargo test --workspace --locked --exclude photocraft-ui-egui
|
||||
CARGO_PROFILE_DEV_DEBUG=0 cargo build --workspace --all-targets --locked --features heif
|
||||
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
|
||||
# The VM's software Vulkan driver crashes (SIGSEGV) when several tests set up a GPU
|
||||
|
||||
@@ -73,6 +73,7 @@ next to the files:
|
||||
| `corpus/photoshop/` (256 PSDs) | Photoshop oracle corpus: smart filters, layer-style effects, type, adjustments in every mode and depth | PhotoCraft contributors (authored with Adobe Photoshop 2026 by a script) | [https://github.com/storytold/photocraft-corpus](https://github.com/storytold/photocraft-corpus) (`photoshop/`, with its generator and README) | MIT OR Apache-2.0 |
|
||||
| `corpus/psd-tools/` (309 files) | psd-tools test set | Kota Yamaguchi and contributors | [psd-tools `tests/psd_files`](https://github.com/psd-tools/psd-tools/tree/main/tests/psd_files) | MIT, Copyright (c) 2019 Kota Yamaguchi |
|
||||
| `corpus/psd/` (170 files) | Small selection of the psd-tools and ag-psd test files | Kota Yamaguchi; Agamnentzar | psd-tools (above) and [ag-psd `test/`](https://github.com/Agamnentzar/ag-psd/tree/master/test) | MIT (both) |
|
||||
| `corpus/heif/` (9 files, 0.1 MB) | HEIC/HEIF test files: `heic-rs/` checkerboards, RGB strips and a grid-tiled photo with EXIF and XMP (synthetic pixels encoded by macOS `sips`, each `.ref.png` Apple's decode); `pillow-heif/` the 10-bit RGBA `RGBA_10__29x100.heif` and its source `RGBA_16__29x100.png` | Thomas Braun (heic-rs); Pillow-Heif contributors | [heic-rs `tests/fixtures`](https://github.com/tbraun96/heic-rs/tree/main/tests/fixtures), [pillow-heif `tests/images`](https://github.com/bigcat88/pillow_heif/tree/master/tests/images) | MIT OR Apache-2.0 (heic-rs); BSD-3-Clause (pillow-heif) |
|
||||
| `corpus/pngsuite/` | PngSuite | Willem van Schaik | <http://www.schaik.com/pngsuite/> | Public domain |
|
||||
|
||||
Files copied into `corpus/` by hand (tiff, exr, raw) must be MIT, BSD or CC0.
|
||||
Generated
+17
@@ -2030,6 +2030,15 @@ dependencies = [
|
||||
"foldhash 0.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heic-rs"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "adf9dde634bb6d5b718bacd01bef80192f2334306b2f785519a0136cca7b9618"
|
||||
dependencies = [
|
||||
"rayon",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hermit-abi"
|
||||
version = "0.5.3"
|
||||
@@ -3503,6 +3512,7 @@ dependencies = [
|
||||
"image",
|
||||
"image-webp",
|
||||
"jpeg-encoder",
|
||||
"photocraft-heif",
|
||||
"png 0.18.1",
|
||||
"proptest",
|
||||
"rayon",
|
||||
@@ -3618,6 +3628,13 @@ dependencies = [
|
||||
"wgpu",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-heif"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"heic-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-io"
|
||||
version = "0.3.0"
|
||||
|
||||
@@ -198,7 +198,7 @@ Every screenshot here is the real app at work on public-domain art, rendered off
|
||||
</td>
|
||||
<td width="33%" valign="top">
|
||||
<h4>🗂️ Formats</h4>
|
||||
PSD and PSB, plus PNG, JPEG, TIFF, WebP, GIF, BMP, TGA, ICO, QOI, PNM, OpenEXR, Radiance HDR and AVIF, with symmetric read and write at 8, 16 and 32 bits, and the native <code>.pcraft</code> format.
|
||||
PSD and PSB, plus PNG, JPEG, TIFF, WebP, GIF, BMP, TGA, ICO, QOI, PNM, OpenEXR, Radiance HDR and AVIF, with symmetric read and write at 8, 16 and 32 bits, HEIC photos from iPhone and Mac (read; in official builds, an optional <code>--features heif</code> build feature), and the native <code>.pcraft</code> format.
|
||||
</td>
|
||||
<td width="33%" valign="top">
|
||||
<h4>🪄 The everyday essentials</h4>
|
||||
|
||||
@@ -5,6 +5,11 @@ version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[features]
|
||||
# Open HEIC/HEIF photos (the optional photocraft-heif crate). Not default so distributors choose
|
||||
# it; official builds (CI, packaging/, release) build with `--features heif`.
|
||||
heif = ["photocraft-codecs/heif"]
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
|
||||
@@ -6,6 +6,11 @@ edition.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
|
||||
[features]
|
||||
# Open HEIC/HEIF photos (the optional photocraft-heif crate). Not default so distributors choose
|
||||
# it; official builds (CI, packaging/, release) build with `--features heif`.
|
||||
heif = ["photocraft-codecs/heif"]
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
|
||||
<title>PhotoCraft</title>
|
||||
<link data-trunk rel="rust" data-bin="photocraft-web" data-cargo-profile="wasm-release" data-wasm-opt="z" data-weak-refs />
|
||||
<link data-trunk rel="rust" data-bin="photocraft-web" data-cargo-profile="wasm-release" data-cargo-features="heif" data-wasm-opt="z" data-weak-refs />
|
||||
<style>
|
||||
html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background: #262626; }
|
||||
#photocraft_canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
|
||||
|
||||
@@ -15,7 +15,7 @@ type Inbox = Arc<Mutex<Vec<(String, Vec<u8>)>>>;
|
||||
/// brushes (.abr) and gradients (.grd), which go to the preset libraries.
|
||||
const OPEN_EXTS: &[&str] = &[
|
||||
"pcraft", "psd", "psb", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", "pbm", "pgm", "ppm", "pam", "pfm",
|
||||
"dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd",
|
||||
"heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd",
|
||||
];
|
||||
const CANVAS_ID: &str = "photocraft_canvas";
|
||||
|
||||
|
||||
@@ -7,6 +7,11 @@ default-run = "photocraft"
|
||||
description = "Photocraft: an open-source, native image editor"
|
||||
build = "build.rs"
|
||||
|
||||
[features]
|
||||
# Open HEIC/HEIF photos (the optional photocraft-heif crate). Not default so distributors choose
|
||||
# it; official builds (CI, packaging/, release) build with `--features heif`.
|
||||
heif = ["photocraft-codecs/heif"]
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ use std::sync::Arc;
|
||||
/// brushes (.abr) and gradients (.grd), which go to the preset libraries.
|
||||
const OPEN_EXTS: &[&str] = &[
|
||||
"pcraft", "psd", "psb", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", "pbm", "pgm", "ppm", "pam", "pfm",
|
||||
"dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd",
|
||||
"heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd",
|
||||
];
|
||||
|
||||
/// File › Save As formats: (filter name, extensions). The filter matching the suggested name's
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Raster formats
|
||||
|
||||
`photocraft-codecs` provides format detection, capability declarations, decode limits, metadata handling, and encoding for flat images. The default build declares read/write support for PNG, JPEG, TIFF, WebP, GIF, BMP, TGA, ICO, Netpbm/PFM, QOI, OpenEXR, and Radiance HDR. AVIF encoding is optional; AVIF decoding is not implemented in the current crate.
|
||||
`photocraft-codecs` provides format detection, capability declarations, decode limits, metadata handling, and encoding for flat images. The default build declares read/write support for PNG, JPEG, TIFF, WebP, GIF, BMP, TGA, ICO, Netpbm/PFM, QOI, OpenEXR, and Radiance HDR. HEIF/HEIC (iPhone and Mac photos) is read-only, decoded in pure Rust by `heic-rs` through the optional `photocraft-heif` crate. It is a build-time choice behind the `heif` cargo feature (`cargo build -p photocraft --features heif`): official builds include it; without it HEIC files are still recognised and opening one reports that HEIC support isn't included in that build. AVIF encoding is optional; AVIF decoding is not implemented in the current crate.
|
||||
|
||||
Capability details such as sample depth, channel layout, alpha, ICC, EXIF, XMP, DPI, and animation behavior are defined by `crates/codecs/src/format.rs`. Do not infer fidelity from the filename extension alone.
|
||||
|
||||
|
||||
@@ -13,6 +13,10 @@ default = []
|
||||
# AVIF *encode* via ravif (pure Rust). Decoding AVIF needs dav1d (C), so AVIF
|
||||
# stays read-unsupported and therefore asymmetric; it is off by default.
|
||||
avif = ["image/avif"]
|
||||
# HEIC/HEIF decode via the optional photocraft-heif crate (heic-rs, pure Rust). Off by default so
|
||||
# distributors choose it at build time; official builds and CI enable it (`--features heif` on the
|
||||
# apps). Without it HEIF files are still recognised and opening one is a clear "unsupported" error.
|
||||
heif = ["dep:photocraft-heif"]
|
||||
|
||||
[dependencies]
|
||||
thiserror.workspace = true
|
||||
@@ -26,6 +30,7 @@ image-webp = "0.2"
|
||||
zune-jpeg = "0.5"
|
||||
zune-core = "0.5"
|
||||
jpeg-encoder = "0.6"
|
||||
photocraft-heif = { path = "../heif", optional = true }
|
||||
|
||||
# Orientation is applied a band of rows per task on native; wasm stays single-threaded.
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
|
||||
+12
-1
@@ -45,6 +45,7 @@ let out = encode(&img, Format::Tiff, &EncodeOptions::default())?;
|
||||
| OpenEXR | yes | yes | F16, F32 | Y, YA, RGB, RGBA | yes | no | no | no | no | no | no | `exr` |
|
||||
| Radiance HDR | yes | yes | F32 | RGB | no | no | no | no | no | no | yes (RGBE) | `image` |
|
||||
| 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` |
|
||||
|
||||
"Native" means the data is stored and read back without conversion. Anything else is converted by
|
||||
the encode plan, and `fidelity_warnings` reports the conversion when it loses information:
|
||||
@@ -65,7 +66,17 @@ the same.
|
||||
with `CodecError::Unsupported`: they are sensor data, not flat images. `photocraft-raw` decodes
|
||||
and develops them, and `photocraft-io` routes them there.
|
||||
|
||||
* **AVIF (the only asymmetric format).** Encoding uses `ravif`, which is pure Rust. Decoding
|
||||
* **HEIF/HEIC (read-only, feature `heif`).** Decoding lives in the optional `photocraft-heif` crate
|
||||
behind the non-default `heif` feature (official PhotoCraft builds enable it; distributors choose).
|
||||
Without it, HEIF is still detected and decoding returns `CodecError::Unsupported` ("HEIC/HEIF
|
||||
support isn't included in this build"). iPhone and Mac photos decode with `heic-rs` (pure Rust, no `unsafe`):
|
||||
single pictures and grid-tiled photos, 8 to 12 bits, auxiliary alpha, ICC, EXIF and XMP. The
|
||||
container's `irot`/`imir`/`clap` are applied and EXIF Orientation is rewritten to 1 (HEIF's EXIF
|
||||
tag only repeats the container's, and applying both would turn the photo twice). Grayscale
|
||||
decodes to RGB. Image sequences, overlays and identity derivations, multilayer HEVC and some
|
||||
4:2:2/4:4:4 streams return `CodecError::Unsupported` or `Malformed`, never wrong pixels. Writing
|
||||
needs an HEVC encoder and every mature one is C, so HEIF is listed in `ASYMMETRIC_EXCEPTIONS`.
|
||||
* **AVIF.** Encoding uses `ravif`, which is pure Rust. Decoding
|
||||
needs `dav1d`, which is C. AVIF is therefore read-unsupported, and write support is gated
|
||||
behind the non-default `avif` feature. In a default build it is neither readable nor writable,
|
||||
so the symmetric guarantee holds. It is listed in `ASYMMETRIC_EXCEPTIONS`.
|
||||
|
||||
@@ -14,7 +14,9 @@ cargo-fuzz = true
|
||||
|
||||
[dependencies]
|
||||
libfuzzer-sys = "0.4"
|
||||
photocraft-codecs = { path = ".." }
|
||||
photocraft-codecs = { path = "..", features = ["heif"] }
|
||||
# Called directly too: the codec turns its panics into errors (see decode_heif.rs).
|
||||
heic-rs = "=0.1.1"
|
||||
|
||||
[profile.release]
|
||||
debug = 1
|
||||
@@ -53,3 +55,10 @@ path = "fuzz_targets/decode_exr.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
||||
[[bin]]
|
||||
name = "decode_heif"
|
||||
path = "fuzz_targets/decode_heif.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
@@ -0,0 +1,15 @@
|
||||
#![no_main]
|
||||
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
use photocraft_codecs::{decode_as_with, DecodeOptions, Format, Limits};
|
||||
|
||||
// heic-rs is young and parses untrusted HEIF/HEVC: run with `-timeout=10` so a hang is reported,
|
||||
// not just a panic. The codec turns a heic-rs panic into an error, so heic-rs is also called
|
||||
// directly, where its panics still reach the fuzzer. Both orientation paths are covered.
|
||||
fuzz_target!(|data: &[u8]| {
|
||||
let keep_orientation = data.first().is_some_and(|b| b & 1 == 1);
|
||||
let raw = heic_rs::DecodeOptions::default().with_max_pixels(Some(1 << 22)).with_transforms(!keep_orientation).with_alpha(true);
|
||||
let _ = heic_rs::decode(data, &raw);
|
||||
let limits = Limits { max_width: 4096, max_height: 4096, max_pixels: 1 << 22, max_alloc: 256 << 20 };
|
||||
let _ = decode_as_with(Format::Heif, data, &DecodeOptions { limits, keep_orientation });
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
//! HEIF / HEIC (the iPhone and Mac photo format), read-only: see [`crate::ASYMMETRIC_EXCEPTIONS`].
|
||||
//!
|
||||
//! Decoding lives in the optional `photocraft-heif` crate (heic-rs, a pure-Rust HEVC
|
||||
//! still-picture decoder), enabled by this crate's `heif` feature. Without it, HEIF files are
|
||||
//! still detected and opening one is an [`CodecError::Unsupported`] error, never a panic.
|
||||
//!
|
||||
//! HEIF records orientation in the container (`irot`/`imir`, plus a `clap` crop), not in EXIF,
|
||||
//! so those are applied by the decoder and the EXIF Orientation is rewritten to 1. Its EXIF tag
|
||||
//! only mirrors what the container already says, and applying it as well would turn the photo
|
||||
//! twice.
|
||||
|
||||
use crate::Format;
|
||||
use crate::error::CodecError;
|
||||
use crate::image::Image;
|
||||
use crate::options::Limits;
|
||||
|
||||
const F: Format = Format::Heif;
|
||||
|
||||
/// The reason given when this build has no HEIF decoder.
|
||||
#[cfg(not(feature = "heif"))]
|
||||
pub(crate) const NOT_IN_BUILD: &str = "HEIC/HEIF support isn't included in this build of PhotoCraft";
|
||||
|
||||
#[cfg(not(feature = "heif"))]
|
||||
pub(crate) fn decode(_bytes: &[u8], _limits: &Limits, _keep_orientation: bool) -> Result<Image, CodecError> {
|
||||
Err(CodecError::unsupported(F, NOT_IN_BUILD))
|
||||
}
|
||||
|
||||
#[cfg(feature = "heif")]
|
||||
fn err(e: photocraft_heif::Error) -> CodecError {
|
||||
match e {
|
||||
photocraft_heif::Error::Unsupported(what) => CodecError::unsupported(F, what),
|
||||
photocraft_heif::Error::Limit(what) => CodecError::LimitExceeded(what),
|
||||
photocraft_heif::Error::Malformed(what) => CodecError::malformed(F, what),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "heif")]
|
||||
pub(crate) fn decode(bytes: &[u8], limits: &Limits, keep_orientation: bool) -> Result<Image, CodecError> {
|
||||
use crate::image::{ChannelLayout, Metadata, SampleType};
|
||||
|
||||
// The container alone: the declared size is checked before any pixel is decoded.
|
||||
let info = photocraft_heif::probe(bytes).map_err(err)?;
|
||||
let layout = if info.has_alpha { ChannelLayout::Rgba } else { ChannelLayout::Rgb };
|
||||
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)?;
|
||||
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.
|
||||
let layout = if decoded.has_alpha { ChannelLayout::Rgba } else { ChannelLayout::Rgb };
|
||||
let sample = if decoded.sixteen_bit { SampleType::U16 } else { SampleType::U8 };
|
||||
let mut img = Image::from_raw(decoded.width, decoded.height, layout, sample, decoded.data)?;
|
||||
img.icc = decoded.icc;
|
||||
img.meta = Metadata {
|
||||
exif: decoded.exif.map(|e| if keep_orientation { e } else { crate::orientation::upright_exif(&e).into_owned() }),
|
||||
xmp: decoded.xmp.map(|x| if keep_orientation { x } else { crate::orientation::upright_xmp(&x).into_owned() }),
|
||||
..Default::default()
|
||||
};
|
||||
Ok(img)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[cfg(not(feature = "heif"))]
|
||||
#[test]
|
||||
fn without_the_feature_heif_is_a_clear_unsupported_error() {
|
||||
let header = b"\0\0\0\x18ftypheic\0\0\0\0mif1heic";
|
||||
assert_eq!(crate::detect(header), Some(F), "still recognised");
|
||||
let r = crate::decode(header);
|
||||
assert!(matches!(&r, Err(CodecError::Unsupported { format: Format::Heif, reason }) if reason.contains("isn't included in this build")), "{r:?}");
|
||||
assert!(!crate::caps(F).read);
|
||||
}
|
||||
|
||||
#[cfg(feature = "heif")]
|
||||
#[test]
|
||||
fn with_the_feature_heif_is_readable_and_errors_map() {
|
||||
assert!(crate::caps(F).read);
|
||||
let r = crate::decode(b"\0\0\0\x18ftypmsf1\0\0\0\0msf1hevc");
|
||||
assert!(matches!(&r, Err(CodecError::Unsupported { format: Format::Heif, reason }) if reason.contains("sequence")), "{r:?}");
|
||||
assert!(matches!(crate::decode_as(F, b""), Err(CodecError::Malformed { .. } | CodecError::Unsupported { .. })));
|
||||
}
|
||||
|
||||
/// Found by the `decode_heif` fuzz target: a malformed box makes heic-rs 0.1.1 slice out of range
|
||||
/// (`boxes.rs:130`, "slice index starts at 24 but ends at 16"). It must be an error, not a crash.
|
||||
#[cfg(feature = "heif")]
|
||||
const HEIC_RS_BOX_PANIC: [u8; 72] = [
|
||||
0x00, 0x00, 0x00, 0x24, 0x66, 0x74, 0x79, 0x70, 0x68, 0x65, 0x69, 0x63, 0x00, 0x00, 0x00, 0x00, 0x6d, 0x69, 0x66, 0x31, 0x4d, 0x69, 0x50, 0x72, 0x6d,
|
||||
0x69, 0x61, 0x66, 0x4d, 0x69, 0x48, 0x42, 0x72, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x6d, 0x65, 0x74, 0x61, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x10, 0x75, 0x75, 0x69, 0x64, 0x00, 0x00, 0x00, 0x00, 0x03, 0x08, 0x08, 0x08, 0x00, 0x03, 0x01, 0x03, 0x70, 0x00, 0xa8, 0x00,
|
||||
];
|
||||
|
||||
#[cfg(feature = "heif")]
|
||||
#[test]
|
||||
fn a_heic_rs_panic_is_a_malformed_file_error() {
|
||||
assert_eq!(crate::detect(&HEIC_RS_BOX_PANIC), Some(Format::Heif));
|
||||
for opts in [crate::DecodeOptions::default(), crate::DecodeOptions { keep_orientation: true, ..Default::default() }] {
|
||||
let r = crate::decode_with(&HEIC_RS_BOX_PANIC, &opts);
|
||||
assert!(matches!(r, Err(CodecError::Malformed { format: Format::Heif, .. })), "{r:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
pub(crate) mod exr;
|
||||
pub(crate) mod heif;
|
||||
pub(crate) mod jpeg;
|
||||
pub(crate) mod png;
|
||||
pub(crate) mod pnm;
|
||||
|
||||
+45
-11
@@ -21,6 +21,8 @@ pub enum Format {
|
||||
Hdr,
|
||||
/// Encode-only behind the `avif` feature; see [`ASYMMETRIC_EXCEPTIONS`].
|
||||
Avif,
|
||||
/// HEIF/HEIC (HEVC-coded): read-only; see [`ASYMMETRIC_EXCEPTIONS`].
|
||||
Heif,
|
||||
}
|
||||
|
||||
/// What a format can hold **and** what this crate reads/writes for it.
|
||||
@@ -51,11 +53,20 @@ pub struct FormatCaps {
|
||||
|
||||
/// Formats that are enabled but intentionally not symmetric, with the reason.
|
||||
/// The test-suite asserts that every other enabled format is read+write.
|
||||
pub const ASYMMETRIC_EXCEPTIONS: &[(Format, &str)] = &[(
|
||||
Format::Avif,
|
||||
"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.",
|
||||
)];
|
||||
pub const ASYMMETRIC_EXCEPTIONS: &[(Format, &str)] = &[
|
||||
(
|
||||
Format::Avif,
|
||||
"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::Heif,
|
||||
"HEIC decode uses heic-rs (pure Rust, in the optional photocraft-heif crate behind the \
|
||||
non-default `heif` feature, which official builds enable), so iPhone and Mac photos open; \
|
||||
writing needs an HEVC encoder, and the mature ones (x265, libheif) are C, so write stays \
|
||||
unsupported. Without the feature HEIF is detected but neither read nor written.",
|
||||
),
|
||||
];
|
||||
|
||||
use ChannelLayout as L;
|
||||
use SampleType as S;
|
||||
@@ -65,7 +76,7 @@ const ALL_LAYOUTS: &[ChannelLayout] = &[L::Gray, L::GrayA, L::Rgb, L::Rgba, L::C
|
||||
|
||||
impl Format {
|
||||
/// Every format known to the crate (enabled or not).
|
||||
pub const ALL: [Format; 13] = [
|
||||
pub const ALL: [Format; 14] = [
|
||||
Format::Png,
|
||||
Format::Jpeg,
|
||||
Format::Tiff,
|
||||
@@ -79,6 +90,7 @@ impl Format {
|
||||
Format::OpenExr,
|
||||
Format::Hdr,
|
||||
Format::Avif,
|
||||
Format::Heif,
|
||||
];
|
||||
|
||||
pub fn caps(self) -> FormatCaps {
|
||||
@@ -106,6 +118,7 @@ impl Format {
|
||||
Format::OpenExr => "OpenEXR",
|
||||
Format::Hdr => "Radiance HDR",
|
||||
Format::Avif => "AVIF",
|
||||
Format::Heif => "HEIF",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,6 +139,7 @@ impl Format {
|
||||
Format::OpenExr => &["exr"],
|
||||
Format::Hdr => &["hdr"],
|
||||
Format::Avif => &["avif"],
|
||||
Format::Heif => &["heic", "heif", "hif"],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,6 +158,7 @@ impl Format {
|
||||
Format::OpenExr => "image/x-exr",
|
||||
Format::Hdr => "image/vnd.radiance",
|
||||
Format::Avif => "image/avif",
|
||||
Format::Heif => "image/heif",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,6 +205,9 @@ pub fn caps(format: Format) -> FormatCaps {
|
||||
Format::OpenExr => FormatCaps { depths: &[S::F16, S::F32], layouts: RGB_GRAY, ..base },
|
||||
Format::Hdr => FormatCaps { depths: &[S::F32], layouts: &[L::Rgb], alpha: false, lossy: true, ..base },
|
||||
Format::Avif => FormatCaps { read: false, write: cfg!(feature = "avif"), lossy: true, ..base },
|
||||
Format::Heif => {
|
||||
FormatCaps { read: cfg!(feature = "heif"), write: false, depths: &[S::U8, S::U16], icc: true, exif: true, xmp: true, lossy: true, ..base }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,8 +239,13 @@ pub fn detect(bytes: &[u8]) -> Option<Format> {
|
||||
if b.starts_with(b"#?RADIANCE") || b.starts_with(b"#?RGBE") {
|
||||
return Some(Format::Hdr);
|
||||
}
|
||||
if b.len() >= 12 && &b[4..8] == b"ftyp" && is_avif_ftyp(b) {
|
||||
return Some(Format::Avif);
|
||||
if b.len() >= 12 && &b[4..8] == b"ftyp" {
|
||||
if is_avif_ftyp(b) {
|
||||
return Some(Format::Avif);
|
||||
}
|
||||
if is_heif_ftyp(b) {
|
||||
return Some(Format::Heif);
|
||||
}
|
||||
}
|
||||
if b.len() >= 14 && b.starts_with(b"BM") {
|
||||
return Some(Format::Bmp);
|
||||
@@ -239,11 +262,22 @@ pub fn detect(bytes: &[u8]) -> Option<Format> {
|
||||
None
|
||||
}
|
||||
|
||||
fn is_avif_ftyp(b: &[u8]) -> bool {
|
||||
/// The `ftyp` brands: the major brand, then the compatible ones (the minor version between them is skipped).
|
||||
fn ftyp_brands(b: &[u8]) -> impl Iterator<Item = &[u8; 4]> {
|
||||
let size = u32::from_be_bytes([b[0], b[1], b[2], b[3]]) as usize;
|
||||
let end = size.clamp(12, b.len().min(64));
|
||||
let brands = &b[8..end];
|
||||
brands.as_chunks::<4>().0.iter().enumerate().any(|(i, c)| i != 1 && (c == b"avif" || c == b"avis"))
|
||||
b[8..end].as_chunks::<4>().0.iter().enumerate().filter(|(i, _)| *i != 1).map(|(_, c)| c)
|
||||
}
|
||||
|
||||
fn is_avif_ftyp(b: &[u8]) -> bool {
|
||||
ftyp_brands(b).any(|c| c == b"avif" || c == b"avis")
|
||||
}
|
||||
|
||||
/// HEVC-coded HEIF (`heic`, `heix`, ...) or a generic HEIF file (`mif1`, `msf1`, `miaf`) that is
|
||||
/// not AVIF (checked first). Sequence-only files (`hevc`, `msf1`) are recognised so that opening
|
||||
/// one says what is unsupported instead of "unrecognized format".
|
||||
fn is_heif_ftyp(b: &[u8]) -> bool {
|
||||
ftyp_brands(b).any(|c| matches!(c, b"heic" | b"heix" | b"heim" | b"heis" | b"hevc" | b"hevx" | b"mif1" | b"msf1" | b"miaf"))
|
||||
}
|
||||
|
||||
fn looks_like_tga(b: &[u8]) -> bool {
|
||||
|
||||
@@ -31,7 +31,7 @@ pub use crate::options::{DecodeOptions, EncodeOptions, ExrCompression, Limits, P
|
||||
pub use crate::orientation::{exif_orientation, upright_exif, upright_xmp};
|
||||
pub use half::f16;
|
||||
|
||||
use crate::codecs::{exr, jpeg, png, pnm, tiff, via_image, webp};
|
||||
use crate::codecs::{exr, heif, jpeg, png, pnm, tiff, via_image, webp};
|
||||
|
||||
/// Detect the format and decode with default [`Limits`].
|
||||
pub fn decode(bytes: &[u8]) -> Result<Image, CodecError> {
|
||||
@@ -51,7 +51,8 @@ pub fn decode_as(format: Format, bytes: &[u8]) -> Result<Image, CodecError> {
|
||||
|
||||
/// Decode as a specific format.
|
||||
pub fn decode_as_with(format: Format, bytes: &[u8], opts: &DecodeOptions) -> Result<Image, CodecError> {
|
||||
if !caps(format).read {
|
||||
// HEIF without the `heif` feature: its decoder stub says what is missing from this build.
|
||||
if !caps(format).read && format != Format::Heif {
|
||||
return Err(CodecError::unsupported(format, "decoding is not available for this format"));
|
||||
}
|
||||
let l = &opts.limits;
|
||||
@@ -62,6 +63,7 @@ pub fn decode_as_with(format: Format, bytes: &[u8], opts: &DecodeOptions) -> Res
|
||||
Format::WebP => webp::decode(bytes, l),
|
||||
Format::Pnm => pnm::decode(bytes, l),
|
||||
Format::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),
|
||||
}?;
|
||||
// Final guard for decoders whose header we could not pre-inspect.
|
||||
@@ -70,9 +72,11 @@ pub fn decode_as_with(format: Format, bytes: &[u8], opts: &DecodeOptions) -> Res
|
||||
return Ok(img);
|
||||
}
|
||||
// Turn the pixels upright, like Photoshop: a TIFF records it in its own IFD0, the others
|
||||
// in their EXIF block. The metadata is rewritten to Orientation = 1 on the way.
|
||||
// in their EXIF block. The metadata is rewritten to Orientation = 1 on the way. HEIF keeps it
|
||||
// in its container, and its decoder has already applied it.
|
||||
let o = match format {
|
||||
Format::Tiff => exif_orientation(bytes),
|
||||
Format::Heif => 1,
|
||||
_ => img.meta.exif.as_deref().map_or(1, exif_orientation),
|
||||
};
|
||||
// Orientations 5–8 swap width and height: with asymmetric limits a stored landscape
|
||||
@@ -106,6 +110,7 @@ pub fn encode(image: &Image, format: Format, opts: &EncodeOptions) -> Result<Vec
|
||||
Format::WebP => webp::encode(image, plan, opts),
|
||||
Format::Pnm => pnm::encode(image, plan, opts),
|
||||
Format::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),
|
||||
}
|
||||
}
|
||||
@@ -71,6 +71,29 @@ fn avif_never_readable() {
|
||||
assert!(matches!(decode_as(Format::Avif, b"\0\0\0\x1cftypavif"), Err(CodecError::Unsupported { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heif_is_detected_always_and_readable_only_with_the_feature() {
|
||||
let header = b"\0\0\0\x18ftypheic\0\0\0\0mif1heic";
|
||||
assert_eq!(detect(header), Some(Format::Heif));
|
||||
assert_eq!(from_extension("IMG_0001.HEIC"), Some(Format::Heif));
|
||||
assert_eq!(caps(Format::Heif).read, cfg!(feature = "heif"));
|
||||
assert!(!caps(Format::Heif).write);
|
||||
assert!(ASYMMETRIC_EXCEPTIONS.iter().any(|(f, _)| *f == Format::Heif));
|
||||
// Either way a bare header is an error, never a panic.
|
||||
assert!(decode_as(Format::Heif, header).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_heif_brands() {
|
||||
assert_eq!(detect(b"\0\0\0\x18ftypheic\0\0\0\0mif1heic"), Some(Format::Heif));
|
||||
assert_eq!(detect(b"\0\0\0\x18ftypmif1\0\0\0\0mif1heic"), Some(Format::Heif), "generic major brand");
|
||||
assert_eq!(detect(b"\0\0\0\x18ftypheix\0\0\0\0mif1heix"), Some(Format::Heif), "10-bit");
|
||||
assert_eq!(detect(b"\0\0\0\x18ftypmsf1\0\0\0\0msf1hevc"), Some(Format::Heif), "image sequence");
|
||||
assert_eq!(detect(b"\0\0\0\x18ftypmif1\0\0\0\0mif1avif"), Some(Format::Avif), "AVIF wins over the generic brand");
|
||||
assert_eq!(detect(b"\0\0\0\x18ftypisom\0\0\0\0isommp41"), None, "mp4 is not HEIF");
|
||||
assert_eq!(detect(b"\0\0\0\x14ftypqt \0\0\0\0qt "), None, "QuickTime is not HEIF");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caps_are_internally_consistent() {
|
||||
for f in Format::ALL {
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
//! HEIF/HEIC decoding against Apple's own encoder and decoder (features `corpus` and `heif`;
|
||||
//! run with `cargo xtask test-corpus`). The fixtures are fetched into `corpus/heif/` by
|
||||
//! `cargo xtask corpus --heif` (pins: `xtask/src/corpus_pins.rs`); a missing corpus fails.
|
||||
//! Most come from heic-rs (`heic-rs/`, MIT OR Apache-2.0): encoded by macOS `sips` from synthetic
|
||||
//! PNGs, each `.ref.png` being Apple's decode of the `.heic` next to it, the ground truth we
|
||||
//! compare with. The 10-bit one is pillow-heif's (`pillow-heif/`, BSD-3-Clause), with the source
|
||||
//! it was encoded from.
|
||||
|
||||
#![cfg(all(feature = "corpus", feature = "heif"))]
|
||||
|
||||
mod common;
|
||||
use common::*;
|
||||
use photocraft_codecs::*;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
/// A file of `corpus/heif/`.
|
||||
fn corpus(rel: &str) -> Vec<u8> {
|
||||
let p = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/heif").join(rel);
|
||||
std::fs::read(&p).unwrap_or_else(|e| panic!("{}: {e}: run `cargo xtask corpus --all`", p.display()))
|
||||
}
|
||||
|
||||
static FILES: LazyLock<[Vec<u8>; 4]> =
|
||||
LazyLock::new(|| ["heic-rs/checker-64.heic", "heic-rs/rgb-strips-96.heic", "heic-rs/checker-1024.heic", "heic-rs/with-exif.heic"].map(corpus));
|
||||
|
||||
fn checker_64() -> &'static [u8] {
|
||||
&FILES[0]
|
||||
}
|
||||
fn strips_96() -> &'static [u8] {
|
||||
&FILES[1]
|
||||
}
|
||||
/// iPhone-style grid of 512x512 tiles (2x2).
|
||||
fn checker_grid() -> &'static [u8] {
|
||||
&FILES[2]
|
||||
}
|
||||
fn with_exif() -> &'static [u8] {
|
||||
&FILES[3]
|
||||
}
|
||||
|
||||
fn reference(png: &[u8]) -> Image {
|
||||
decode_as(Format::Png, png).unwrap().convert(ChannelLayout::Rgb, SampleType::U8)
|
||||
}
|
||||
|
||||
/// `bytes` with the first `irot` box set to `angle` × 90° anticlockwise (every fixture has one, at 0).
|
||||
fn with_irot(bytes: &[u8], angle: u8) -> Vec<u8> {
|
||||
let at = bytes.windows(4).position(|w| w == b"irot").unwrap() + 4;
|
||||
let mut b = bytes.to_vec();
|
||||
b[at] = angle;
|
||||
b
|
||||
}
|
||||
|
||||
/// `bytes` with the big-endian EXIF Orientation entry (SHORT, count 1) set to `o`.
|
||||
fn with_exif_orientation(bytes: &[u8], o: u8) -> Vec<u8> {
|
||||
let at = bytes.windows(8).position(|w| w == [0x01, 0x12, 0, 3, 0, 0, 0, 1]).unwrap() + 9;
|
||||
let mut b = bytes.to_vec();
|
||||
b[at] = o;
|
||||
b
|
||||
}
|
||||
|
||||
fn keep() -> DecodeOptions {
|
||||
DecodeOptions { keep_orientation: true, ..Default::default() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heif_is_read_only_and_listed() {
|
||||
let c = caps(Format::Heif);
|
||||
assert!(c.read && !c.write);
|
||||
assert!(ASYMMETRIC_EXCEPTIONS.iter().any(|(f, _)| *f == Format::Heif));
|
||||
for ext in ["heic", "HEIC", "photo.heif", "IMG_0001.HIF"] {
|
||||
assert_eq!(from_extension(ext), Some(Format::Heif), "{ext}");
|
||||
}
|
||||
let img = decode(checker_64()).unwrap();
|
||||
assert!(matches!(encode(&img, Format::Heif, &EncodeOptions::default()), Err(CodecError::Unsupported { .. })));
|
||||
assert_eq!(fidelity_warnings(&img, Format::Heif), vec![FidelityWarning::WriteUnsupported { format: Format::Heif }]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_heif_brands() {
|
||||
for f in [checker_64(), strips_96(), checker_grid(), with_exif()] {
|
||||
assert_eq!(detect(f), Some(Format::Heif));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_picture_matches_apple_decode() {
|
||||
for (heic, png) in [(checker_64(), "heic-rs/checker-64.ref.png"), (strips_96(), "heic-rs/rgb-strips-96.ref.png")] {
|
||||
let img = decode(heic).unwrap();
|
||||
let want = reference(&corpus(png));
|
||||
assert_eq!((img.layout(), img.sample_type()), (ChannelLayout::Rgb, SampleType::U8));
|
||||
assert_eq!(img.dimensions(), want.dimensions());
|
||||
assert!(max_abs_diff(&img, &want) <= 3.0 / 255.0, "max diff {}", max_abs_diff(&img, &want) * 255.0);
|
||||
assert!(psnr(&img, &want) > 45.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grid_tiled_photo_matches_apple_decode() {
|
||||
// iPhone photos are grids of 512x512 HEVC tiles: here 2x2.
|
||||
let img = decode(checker_grid()).unwrap();
|
||||
let want = reference(&corpus("heic-rs/checker-1024.ref.png"));
|
||||
assert_eq!(img.dimensions(), (1024, 1024));
|
||||
assert!(max_abs_diff(&img, &want) <= 3.0 / 255.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rgb_strips_keep_their_colours() {
|
||||
// Three vertical strips, pure red, green and blue: catches swapped channels or matrices.
|
||||
let img = decode(strips_96()).unwrap();
|
||||
for (x, c) in [(16, 0), (48, 1), (80, 2)] {
|
||||
for ch in 0..3 {
|
||||
let v = img.get(x, 16, ch);
|
||||
assert!(if ch == c { v > 0.9 } else { v < 0.1 }, "strip at x={x} channel {ch} = {v}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn container_rotation_is_applied_once() {
|
||||
// irot 1 = 90° anticlockwise: the red strip (left) ends at the bottom, blue (right) on top.
|
||||
let rotated = with_irot(strips_96(), 1);
|
||||
let img = decode(&rotated).unwrap();
|
||||
assert_eq!(img.dimensions(), (32, 96));
|
||||
assert!(img.get(16, 8, 2) > 0.9 && img.get(16, 8, 0) < 0.1, "blue on top");
|
||||
assert!(img.get(16, 88, 0) > 0.9 && img.get(16, 88, 2) < 0.1, "red at the bottom");
|
||||
// keep_orientation hands back the pixels as coded.
|
||||
assert_eq!(decode_with(&rotated, &keep()).unwrap().dimensions(), (96, 32));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exif_and_xmp_survive_and_exif_orientation_is_not_applied_twice() {
|
||||
let img = decode(with_exif()).unwrap();
|
||||
assert_eq!(img.dimensions(), (2048, 1536));
|
||||
let exif = img.meta.exif.as_deref().unwrap();
|
||||
assert!(exif.starts_with(b"MM\0*"), "TIFF-structured EXIF, without the HEIF offset header");
|
||||
assert!(img.meta.xmp.as_deref().unwrap().contains("x:xmpmeta"));
|
||||
|
||||
// HEIF's EXIF Orientation repeats what irot says; the container is the authority.
|
||||
let tagged = with_exif_orientation(with_exif(), 6);
|
||||
let img = decode(&tagged).unwrap();
|
||||
assert_eq!(img.dimensions(), (2048, 1536), "not turned by the EXIF tag");
|
||||
assert_eq!(exif_orientation(img.meta.exif.as_deref().unwrap()), 1, "rewritten so exports don't turn it either");
|
||||
let raw = decode_with(&tagged, &keep()).unwrap();
|
||||
assert_eq!(exif_orientation(raw.meta.exif.as_deref().unwrap()), 6, "kept verbatim when asked");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn limits_are_checked_before_decoding() {
|
||||
let tight = |limits| DecodeOptions { limits, ..Default::default() };
|
||||
let small = Limits { max_width: 512, ..Default::default() };
|
||||
assert!(matches!(decode_with(checker_grid(), &tight(small)), Err(CodecError::LimitExceeded(_))));
|
||||
let few = Limits { max_pixels: 1000, ..Default::default() };
|
||||
assert!(matches!(decode_with(checker_64(), &tight(few)), Err(CodecError::LimitExceeded(_))));
|
||||
let bytes = Limits { max_alloc: 64 * 64 * 3 - 1, ..Default::default() };
|
||||
assert!(matches!(decode_with(checker_64(), &tight(bytes)), Err(CodecError::LimitExceeded(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_and_broken_files_are_clear_errors() {
|
||||
// An ftyp and nothing else: a HEIF image sequence would have a moov here instead of a meta.
|
||||
let r = decode(b"\0\0\0\x18ftypmsf1\0\0\0\0msf1hevc");
|
||||
assert!(matches!(&r, Err(CodecError::Unsupported { format: Format::Heif, reason }) if reason.contains("sequence")), "{r:?}");
|
||||
assert!(decode_as(Format::Heif, b"").is_err());
|
||||
assert!(decode_as(Format::Heif, &checker_grid()[..checker_grid().len() / 2]).is_err(), "half a file");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_truncation_errors_or_decodes_never_panics() {
|
||||
for bytes in [checker_64(), strips_96(), checker_grid()] {
|
||||
for cut in 0..bytes.len() {
|
||||
let _ = decode_as(Format::Heif, &bytes[..cut]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ten_bit_with_alpha_decodes_to_16_bit_rgba() {
|
||||
// pillow-heif's fixture: a 16-bit RGBA gradient (the .src.png) encoded as 10-bit HEIF by libheif/x265.
|
||||
let img = decode(&corpus("pillow-heif/heif/RGBA_10__29x100.heif")).unwrap();
|
||||
let src = decode_as(Format::Png, &corpus("pillow-heif/non_heif/RGBA_16__29x100.png")).unwrap();
|
||||
assert_eq!((img.dimensions(), img.layout(), img.sample_type()), ((29, 100), ChannelLayout::Rgba, SampleType::U16));
|
||||
// Depth is kept, not 8 bits stretched: most samples are not multiples of 257.
|
||||
let samples = img.to_u16_samples().unwrap();
|
||||
assert!(samples.iter().filter(|v| *v % 257 != 0).count() > samples.len() / 2);
|
||||
// The alpha plane is coded on its own and comes back almost exactly.
|
||||
for y in 0..100 {
|
||||
for x in 0..29 {
|
||||
assert!((img.get(x, y, 3) - src.get(x, y, 3)).abs() < 0.002, "alpha at {x},{y}");
|
||||
}
|
||||
}
|
||||
// Colour went through 4:2:0 chroma on a 29-pixel-wide saturated gradient, which costs about
|
||||
// 26 dB at any depth (the 8-bit encode of the same source scores the same, as does a second
|
||||
// decoder); a decoder bug (range, matrix, bit shift) lands far below.
|
||||
let rgb = |i: &Image| i.convert(ChannelLayout::Rgb, SampleType::U16);
|
||||
assert!(psnr(&rgb(&img), &rgb(&src)) > 24.0, "{}", psnr(&rgb(&img), &rgb(&src)));
|
||||
}
|
||||
@@ -20,6 +20,14 @@ fn samples() -> Vec<(Format, Vec<u8>)> {
|
||||
v.push((f, encode(&img, f, &EncodeOptions::default()).unwrap()));
|
||||
}
|
||||
}
|
||||
// Read-only formats: Apple-encoded HEIC from `corpus/heif` (`cargo xtask corpus --heif`), a
|
||||
// single picture and a grid of tiles.
|
||||
#[cfg(all(feature = "corpus", feature = "heif"))]
|
||||
for name in ["rgb-strips-96.heic", "checker-1024.heic"] {
|
||||
let p = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/heif/heic-rs").join(name);
|
||||
let bytes = std::fs::read(&p).unwrap_or_else(|e| panic!("{}: {e}: run `cargo xtask corpus --all`", p.display()));
|
||||
v.push((Format::Heif, bytes));
|
||||
}
|
||||
// Extra variants: interlaced PNG, ASCII PNM, CMYK JPEG.
|
||||
let img = synth(19, 13, ChannelLayout::Rgb, SampleType::U8, 1, 0.3);
|
||||
v.push((Format::Png, encode(&img, Format::Png, &EncodeOptions { png_interlaced: true, ..Default::default() }).unwrap()));
|
||||
@@ -178,10 +186,11 @@ proptest! {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn random_bytes_with_magic_never_panic(idx in 0usize..13, data in proptest::collection::vec(any::<u8>(), 0..1024)) {
|
||||
let magics: [&[u8]; 13] = [
|
||||
fn random_bytes_with_magic_never_panic(idx in 0usize..14, data in proptest::collection::vec(any::<u8>(), 0..1024)) {
|
||||
let magics: [&[u8]; 14] = [
|
||||
b"\x89PNG\r\n\x1a\n", &[0xFF, 0xD8, 0xFF], b"II*\0", b"RIFF\0\0\0\0WEBP", b"GIF89a", b"BM",
|
||||
&[0, 0, 1, 0, 1, 0], b"P6\n", b"qoif", &[0x76, 0x2F, 0x31, 0x01], b"#?RADIANCE\n", b"P7\n", b"PF\n",
|
||||
b"\0\0\0\x18ftypheic\0\0\0\0mif1heic",
|
||||
];
|
||||
let mut b = magics[idx].to_vec();
|
||||
b.extend_from_slice(&data);
|
||||
@@ -189,7 +198,7 @@ proptest! {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn random_bytes_each_format_never_panic(idx in 0usize..13, data in proptest::collection::vec(any::<u8>(), 0..512)) {
|
||||
fn random_bytes_each_format_never_panic(idx in 0usize..Format::ALL.len(), data in proptest::collection::vec(any::<u8>(), 0..512)) {
|
||||
let _ = decode_as_with(Format::ALL[idx], &data, &tight());
|
||||
}
|
||||
|
||||
|
||||
@@ -116,8 +116,8 @@ pub(crate) fn list_images(dir: &str) -> Result<Vec<String>> {
|
||||
|
||||
/// Extensions the batch commands pick up from a folder.
|
||||
const OPENABLE: &[&str] = &[
|
||||
"psd", "psb", "pcraft", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "exr", "hdr", "qoi", "ico", "pnm", "ppm", "pgm", "dng", "cr2",
|
||||
"nef", "nrw", "arw", "pef",
|
||||
"psd", "psb", "pcraft", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "exr", "hdr", "qoi", "ico", "pnm", "ppm", "pgm", "heic", "heif",
|
||||
"hif", "dng", "cr2", "nef", "nrw", "arw", "pef",
|
||||
];
|
||||
|
||||
pub(crate) fn file_name(path: &str) -> String {
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
[package]
|
||||
name = "photocraft-heif"
|
||||
description = "Optional HEIF/HEIC still-image decoder for PhotoCraft (a thin, panic-guarded wrapper around heic-rs)"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
# Pure Rust, no unsafe. Pinned exactly: a young, single-owner crate that parses untrusted files;
|
||||
# moving the pin is a reviewed change (re-run the fuzz target and the heif corpus tests).
|
||||
heic-rs = { version = "=0.1.1", default-features = false, features = ["std"] }
|
||||
|
||||
# The tiles of a HEIC photo decode in parallel on native; wasm stays single-threaded.
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
heic-rs = { version = "=0.1.1", default-features = false, features = ["std", "parallel"] }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,34 @@
|
||||
# photocraft-heif
|
||||
|
||||
The optional HEIF/HEIC decoder of PhotoCraft: iPhone and Mac photos. A thin, panic-guarded wrapper
|
||||
around [`heic-rs`](https://github.com/tbraun96/heic-rs), a pure-Rust HEVC still-picture decoder
|
||||
(no `unsafe`, MIT OR Apache-2.0): single pictures and grid-tiled photos, 8- and 10-bit (10-bit
|
||||
decodes to 16-bit), alpha auxiliary images, the container's rotation/mirror/crop, ICC, EXIF and XMP.
|
||||
Read-only: writing would need an HEVC encoder.
|
||||
|
||||
```rust
|
||||
let info = photocraft_heif::probe(&bytes)?; // size, depth, alpha: check limits first
|
||||
let img = photocraft_heif::decode(&bytes, &photocraft_heif::Options::default())?;
|
||||
// img.width, img.height, img.has_alpha, img.sixteen_bit, img.data (RGB/RGBA), img.icc, img.exif, img.xmp
|
||||
```
|
||||
|
||||
Errors are `Error::Unsupported` (image sequences, overlays, …), `Error::Limit` or
|
||||
`Error::Malformed`. It never panics: every call into heic-rs runs under `catch_unwind`, and a panic
|
||||
inside it (fuzzing found two in 0.1.1) becomes `Error::Malformed`.
|
||||
|
||||
## Why it is a separate, optional crate
|
||||
|
||||
- **A young decoder.** heic-rs is new and has a single maintainer. It is pinned exactly
|
||||
(`=0.1.1`); moving the pin is a reviewed change (re-run `cargo fuzz run decode_heif` in
|
||||
`crates/codecs` and `cargo xtask test-corpus`).
|
||||
- **HEVC patents are a distributor's call.** HEVC is patent-encumbered in some jurisdictions, so
|
||||
whether a build includes an HEVC decoder is a build-time choice. `photocraft-codecs` uses this
|
||||
crate only behind its non-default `heif` feature; the apps forward it
|
||||
(`cargo build -p photocraft --features heif`). Official PhotoCraft builds and CI enable it.
|
||||
Without it, HEIC files are still recognised and opening one is a clear "HEIC/HEIF support isn't
|
||||
included in this build" error.
|
||||
|
||||
The crate depends on no other PhotoCraft crate and knows nothing of `photocraft-codecs`' types;
|
||||
`codecs` → `heif` is the one allowed dependency between standalone crates (`cargo xtask layers`).
|
||||
Tests on real files live in `photocraft-codecs` (`tests/heif.rs`, features `corpus,heif`, files
|
||||
fetched by `cargo xtask corpus --heif`).
|
||||
@@ -0,0 +1,218 @@
|
||||
//! `photocraft-heif`: the optional HEIF / HEIC (iPhone and Mac photo) decoder.
|
||||
//!
|
||||
//! A thin wrapper around `heic-rs`, a pure-Rust HEVC still-picture decoder: single pictures and
|
||||
//! grid-tiled photos, 8- and 10-bit (10-bit decodes to 16-bit), alpha auxiliary images, ICC,
|
||||
//! EXIF and XMP. Its API is plain data ([`Info`], [`Decoded`], [`Error`]) so the crate knows
|
||||
//! nothing about the rest of PhotoCraft; `photocraft-codecs` adapts it behind its `heif` feature.
|
||||
//!
|
||||
//! HEIF records orientation in the container (`irot`/`imir`, plus a `clap` crop), not in EXIF.
|
||||
//! [`Options::apply_transforms`] applies them; with it off, the pixels come back as coded.
|
||||
//!
|
||||
//! Never panics: heic-rs is young, so every call into it runs under `catch_unwind` and a panic
|
||||
//! inside it becomes [`Error::Malformed`].
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
use heic_rs::PixelLayout;
|
||||
|
||||
/// What a HEIF file declares, read from the container alone (no pixel is decoded).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Info {
|
||||
/// Size after the container transforms (rotation, mirror, crop): what a viewer shows.
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
/// Size as coded, before the transforms.
|
||||
pub coded_width: u32,
|
||||
pub coded_height: u32,
|
||||
/// The primary image has an alpha auxiliary image.
|
||||
pub has_alpha: bool,
|
||||
/// More than 8 bits per sample: it decodes to 16-bit.
|
||||
pub sixteen_bit: bool,
|
||||
}
|
||||
|
||||
/// Decode settings.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Options {
|
||||
/// Refuse images with more pixels than this (checked before decoding).
|
||||
pub max_pixels: u64,
|
||||
/// Apply the container's rotation, mirror and crop (`irot`/`imir`/`clap`).
|
||||
pub apply_transforms: bool,
|
||||
}
|
||||
|
||||
impl Default for Options {
|
||||
fn default() -> Self {
|
||||
Options { max_pixels: 1 << 28, apply_transforms: true }
|
||||
}
|
||||
}
|
||||
|
||||
/// A decoded image: interleaved RGB or RGBA samples, row-major, no padding. 16-bit samples are
|
||||
/// native-endian `u16`s.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Decoded {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
/// Four channels (RGBA) instead of three (RGB).
|
||||
pub has_alpha: bool,
|
||||
/// Two bytes per sample instead of one.
|
||||
pub sixteen_bit: bool,
|
||||
pub data: Vec<u8>,
|
||||
/// The primary image's ICC profile.
|
||||
pub icc: Option<Vec<u8>>,
|
||||
/// EXIF as a TIFF structure (without HEIF's offset header). Its Orientation tag only mirrors
|
||||
/// what the container says; it is returned verbatim.
|
||||
pub exif: Option<Vec<u8>>,
|
||||
/// The XMP packet.
|
||||
pub xmp: Option<String>,
|
||||
}
|
||||
|
||||
/// Why a file could not be decoded.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Error {
|
||||
/// A valid file using something this decoder does not handle (image sequences, overlays…).
|
||||
Unsupported(String),
|
||||
/// The image is larger than [`Options::max_pixels`] or holds an oversized box.
|
||||
Limit(String),
|
||||
/// Broken or truncated data (or a decoder bug, reported the same way).
|
||||
Malformed(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Error {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Error::Unsupported(m) => write!(f, "unsupported HEIF: {m}"),
|
||||
Error::Limit(m) => write!(f, "limit exceeded: {m}"),
|
||||
Error::Malformed(m) => write!(f, "malformed HEIF: {m}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for Error {}
|
||||
|
||||
const SEQUENCE: &str = "this file holds an image sequence; only HEIF still images can be opened";
|
||||
|
||||
fn err(e: heic_rs::Error) -> Error {
|
||||
match e {
|
||||
heic_rs::Error::Unsupported(what) => Error::Unsupported(what.to_string()),
|
||||
// The primary item is neither an HEVC picture nor a grid of them.
|
||||
heic_rs::Error::MissingBox("hvcC") => Error::Unsupported("the image is an overlay, an identity derivation or not HEVC-coded".into()),
|
||||
heic_rs::Error::PixelLimit { .. } | heic_rs::Error::BoxTooLarge { .. } => Error::Limit(e.to_string()),
|
||||
e => Error::Malformed(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs `f`, turning a heic-rs panic into an error. Fuzzing found two out-of-range slices in
|
||||
/// heic-rs 0.1.1 on malformed files: in its box parser (`boxes.rs:130`) and during reconstruction
|
||||
/// (`hevc/decode/recon.rs:70`). The codecs fuzz target also calls heic-rs directly, so its panics
|
||||
/// stay visible there.
|
||||
fn guarded<T>(f: impl FnOnce() -> Result<T, Error>) -> Result<T, Error> {
|
||||
std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)).unwrap_or_else(|_| Err(Error::Malformed("the HEIF decoder failed on this file".into())))
|
||||
}
|
||||
|
||||
/// Reads the container's declared size, depth and alpha without decoding pixels, so callers can
|
||||
/// check their limits first.
|
||||
pub fn probe(bytes: &[u8]) -> Result<Info, Error> {
|
||||
guarded(|| probe_unguarded(bytes))
|
||||
}
|
||||
|
||||
fn probe_unguarded(bytes: &[u8]) -> Result<Info, Error> {
|
||||
let info = heic_rs::probe(bytes).map_err(|e| match e {
|
||||
// No `meta` box: no still image, only a `moov` image sequence (a video track).
|
||||
heic_rs::Error::MissingBox("meta") => Error::Unsupported(SEQUENCE.into()),
|
||||
e => err(e),
|
||||
})?;
|
||||
Ok(Info {
|
||||
width: info.width,
|
||||
height: info.height,
|
||||
coded_width: info.coded_width,
|
||||
coded_height: info.coded_height,
|
||||
has_alpha: info.has_alpha,
|
||||
sixteen_bit: info.bit_depth > 8,
|
||||
})
|
||||
}
|
||||
|
||||
/// Decodes the primary image, with its metadata.
|
||||
pub fn decode(bytes: &[u8], options: &Options) -> Result<Decoded, Error> {
|
||||
guarded(|| decode_unguarded(bytes, options))
|
||||
}
|
||||
|
||||
fn decode_unguarded(bytes: &[u8], options: &Options) -> Result<Decoded, Error> {
|
||||
let info = probe_unguarded(bytes)?;
|
||||
let pixel_layout = match (info.has_alpha, info.sixteen_bit) {
|
||||
(true, true) => PixelLayout::Rgba16,
|
||||
(true, false) => PixelLayout::Rgba8,
|
||||
(false, true) => PixelLayout::Rgb16,
|
||||
(false, false) => PixelLayout::Rgb8,
|
||||
};
|
||||
let raw = heic_rs::DecodeOptions::default()
|
||||
.with_layout(pixel_layout)
|
||||
.with_max_pixels(Some(options.max_pixels))
|
||||
.with_transforms(options.apply_transforms)
|
||||
.with_alpha(info.has_alpha);
|
||||
let decoded = heic_rs::decode(bytes, &raw).map_err(err)?;
|
||||
let (icc, exif, xmp) = metadata(bytes);
|
||||
Ok(Decoded { width: decoded.width, height: decoded.height, has_alpha: info.has_alpha, sixteen_bit: info.sixteen_bit, data: decoded.data, icc, exif, xmp })
|
||||
}
|
||||
|
||||
/// The primary image's ICC profile, EXIF (TIFF structure) and XMP packet. Metadata is a
|
||||
/// courtesy: a broken metadata item never fails a decode whose pixels came out fine.
|
||||
fn metadata(bytes: &[u8]) -> (Option<Vec<u8>>, Option<Vec<u8>>, Option<String>) {
|
||||
let Ok(ctx) = heic_rs::context::Context::open(bytes) else {
|
||||
return (None, None, None);
|
||||
};
|
||||
let id = ctx.meta.primary;
|
||||
let icc = ctx.props(id).ok().and_then(|p| ctx.icc(&p).map(<[u8]>::to_vec));
|
||||
let exif = ctx.exif(id).ok().flatten().map(<[u8]>::to_vec);
|
||||
let xmp = ctx.xmp_item(id).and_then(|x| ctx.item_data(x).ok()).and_then(|d| match d {
|
||||
Cow::Borrowed(b) => String::from_utf8(b.to_vec()).ok(),
|
||||
Cow::Owned(b) => String::from_utf8(b).ok(),
|
||||
});
|
||||
(icc, exif, xmp.map(|x| x.trim_end_matches('\0').to_string()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Found by the `decode_heif` fuzz target: a malformed box makes heic-rs 0.1.1 slice out of
|
||||
/// range (`boxes.rs:130`). It must be an error, not a crash.
|
||||
const HEIC_RS_BOX_PANIC: [u8; 72] = [
|
||||
0x00, 0x00, 0x00, 0x24, 0x66, 0x74, 0x79, 0x70, 0x68, 0x65, 0x69, 0x63, 0x00, 0x00, 0x00, 0x00, 0x6d, 0x69, 0x66, 0x31, 0x4d, 0x69, 0x50, 0x72, 0x6d,
|
||||
0x69, 0x61, 0x66, 0x4d, 0x69, 0x48, 0x42, 0x72, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x6d, 0x65, 0x74, 0x61, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x10, 0x75, 0x75, 0x69, 0x64, 0x00, 0x00, 0x00, 0x00, 0x03, 0x08, 0x08, 0x08, 0x00, 0x03, 0x01, 0x03, 0x70, 0x00, 0xa8, 0x00,
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn a_heic_rs_panic_is_a_malformed_error() {
|
||||
for apply_transforms in [true, false] {
|
||||
let r = decode(&HEIC_RS_BOX_PANIC, &Options { apply_transforms, ..Default::default() });
|
||||
assert!(matches!(r, Err(Error::Malformed(_))), "{r:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_sequence_is_unsupported_with_a_clear_message() {
|
||||
// An ftyp and nothing else: a HEIF image sequence would have a moov here instead of a meta.
|
||||
let bytes = b"\0\0\0\x18ftypmsf1\0\0\0\0msf1hevc";
|
||||
for r in [probe(bytes).map(|_| ()), decode(bytes, &Options::default()).map(|_| ())] {
|
||||
assert!(matches!(&r, Err(Error::Unsupported(m)) if m.contains("sequence")), "{r:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn garbage_is_an_error() {
|
||||
for bytes in [&b""[..], b"\0\0\0\x18ftypheic\0\0\0\0mif1heic", &[0xFF; 64]] {
|
||||
assert!(probe(bytes).is_err());
|
||||
assert!(decode(bytes, &Options::default()).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn errors_display_their_kind() {
|
||||
assert!(Error::Unsupported("x".into()).to_string().contains("unsupported"));
|
||||
assert!(Error::Limit("x".into()).to_string().contains("limit"));
|
||||
assert!(Error::Malformed("x".into()).to_string().contains("malformed"));
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,8 @@ workspace = true
|
||||
[features]
|
||||
# Real-file corpus tests (opt-in; `cargo xtask test-corpus`). Fail when the corpus is missing.
|
||||
corpus = []
|
||||
# HEIC/HEIF decode (forwarded to photocraft-codecs; see its `heif` feature).
|
||||
heif = ["photocraft-codecs/heif"]
|
||||
|
||||
[dependencies]
|
||||
photocraft-geom = { workspace = true }
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
//! HEIC photos (#355) open as documents: through the same `import` File › Open uses, upright,
|
||||
//! with their EXIF and XMP, and an export never turns them a second time. Features `corpus` and
|
||||
//! `heif` (`cargo xtask test-corpus`); the files come from `corpus/heif` (`cargo xtask corpus --heif`).
|
||||
|
||||
#![cfg(all(feature = "corpus", feature = "heif"))]
|
||||
|
||||
use photocraft_codecs::{Format, decode, detect, exif_orientation};
|
||||
use photocraft_doc::{ColorMode, SampleType};
|
||||
use photocraft_io::{ExportOptions, export, import};
|
||||
|
||||
/// A file of `corpus/heif/`; a missing corpus fails.
|
||||
fn corpus(rel: &str) -> Vec<u8> {
|
||||
let p = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/heif").join(rel);
|
||||
std::fs::read(&p).unwrap_or_else(|e| panic!("{}: {e}: run `cargo xtask corpus --all`", p.display()))
|
||||
}
|
||||
|
||||
fn strips() -> Vec<u8> {
|
||||
corpus("heic-rs/rgb-strips-96.heic")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heic_opens_as_an_8_bit_rgb_document() {
|
||||
let r = import("IMG_0001.HEIC", &strips()).unwrap();
|
||||
let d = &r.document;
|
||||
assert_eq!((d.size.width, d.size.height), (96, 32));
|
||||
assert_eq!((d.mode, d.depth), (ColorMode::Rgb, SampleType::U8));
|
||||
assert_eq!(d.layers.len(), 1);
|
||||
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ten_bit_heic_opens_as_a_16_bit_document_with_transparency() {
|
||||
let d = import("hdr.heic", &corpus("pillow-heif/heif/RGBA_10__29x100.heif")).unwrap().document;
|
||||
assert_eq!((d.size.width, d.size.height), (29, 100));
|
||||
assert_eq!((d.mode, d.depth), (ColorMode::Rgb, SampleType::U16));
|
||||
assert!(!d.layers[0].locks.transparency, "an image with alpha opens as an unlocked layer");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heic_metadata_reaches_the_document_and_exports_upright() {
|
||||
let r = import("photo.heic", &corpus("heic-rs/with-exif.heic")).unwrap();
|
||||
let d = &r.document;
|
||||
assert_eq!((d.size.width, d.size.height), (2048, 1536));
|
||||
assert!(d.metadata.exif.is_some() && d.metadata.xmp.as_deref().is_some_and(|x| x.contains("x:xmpmeta")));
|
||||
// HEIC is read-only: save the photo as a JPEG, which keeps the EXIF with Orientation 1.
|
||||
let out = export(d, "photo.jpg", &ExportOptions::default()).unwrap();
|
||||
assert_eq!(detect(&out.bytes), Some(Format::Jpeg));
|
||||
let back = decode(&out.bytes).unwrap();
|
||||
assert_eq!(back.dimensions(), (2048, 1536));
|
||||
assert_eq!(exif_orientation(back.meta.exif.as_deref().unwrap()), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heic_cannot_be_written_and_says_so() {
|
||||
let d = import("x.heic", &strips()).unwrap().document;
|
||||
assert!(export(&d, "x.heic", &ExportOptions::default()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn broken_heic_is_an_error_not_a_crash() {
|
||||
let strips = strips();
|
||||
for cut in [0, 12, 40, strips.len() / 2, strips.len() - 1] {
|
||||
assert!(import("x.heic", &strips[..cut]).is_err(), "cut at {cut}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
//! Without the `heif` feature a HEIC file is still recognised, and opening it is a clear error
|
||||
//! naming what this build lacks, never a panic or "unrecognized format".
|
||||
|
||||
#![cfg(not(feature = "heif"))]
|
||||
|
||||
use photocraft_io::import;
|
||||
|
||||
#[test]
|
||||
fn heic_without_the_feature_says_it_is_not_in_this_build() {
|
||||
let header = b"\0\0\0\x18ftypheic\0\0\0\0mif1heic";
|
||||
for name in ["IMG_0001.HEIC", "photo.heif", "noext"] {
|
||||
let e = import(name, header).err().map(|e| e.to_string()).unwrap_or_default();
|
||||
assert!(e.contains("isn't included in this build"), "{name}: {e}");
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,7 @@ This plan sets out:
|
||||
4. **One algorithm, one parameter struct.** Every algorithm has a CPU reference implementation (Rust + rayon, deterministic). Some also get a GPU implementation (WGSL). Both read the same `#[repr(C)]` `bytemuck::Pod` parameter struct. We never keep hand-synced copies of an algorithm in several languages.
|
||||
5. **Immutable snapshots, copy-on-write tiles.** Pixel data is stored in `Arc`-shared 256² tiles. Taking a document snapshot is O(layers), which makes undo, background jobs, autosave and UI reads cheap and lock-free.
|
||||
6. **Determinism.** The same input gives the same output across CPU and GPU (within tolerance), across thread counts, and between preview and export. Noise is hashed from document coordinates.
|
||||
7. **Pure Rust by default.** C/C++ dependencies are allowed only behind Cargo features, in I/O-edge crates (for example HEIF, or an optional LibRaw).
|
||||
7. **Pure Rust by default.** C/C++ dependencies are allowed only behind Cargo features, in I/O-edge crates (for example an optional LibRaw).
|
||||
8. **Clean-room.** We study other editors' *behaviour* only, and we must **not copy proprietary source** (Rust, WGSL, C++ or JS), even where it ships as readable source. Specs come from public format docs (Adobe PSD spec, ISO/ICC), academic papers (PatchMatch, Poisson blending, ARAP) and observed behaviour. Never paste code from another product.
|
||||
|
||||
### 1.1 Avoiding GIMP's hole
|
||||
@@ -97,7 +97,8 @@ photocraft/
|
||||
│ │ ── I/O ──
|
||||
│ ├─ psd/ photocraft-psd PSD/PSB read + write; its OWN format-level model; depends on nothing in this workspace
|
||||
│ ├─ adobe-assets/ photocraft-adobe-assets .abr .asl .aco/.ase .grd .pat .csh .atn .cube/.3dl, ACR .xmp presets (standalone, like psd)
|
||||
│ ├─ codecs/ photocraft-codecs png/jpeg/tiff/webp/gif/bmp/avif/jxl (+heif via feature) decode/encode
|
||||
│ ├─ codecs/ photocraft-codecs png/jpeg/tiff/webp/gif/bmp/avif decode/encode, heif/heic decode (feature `heif`)
|
||||
│ ├─ heif/ photocraft-heif optional HEIF/HEIC decoder (heic-rs, pure Rust); used only by codecs behind its `heif` feature (standalone)
|
||||
│ ├─ raw/ photocraft-raw clean-room camera RAW decode (DNG, CR2, TIFF/EP) + develop pipeline (standalone, like psd)
|
||||
│ ├─ format/ photocraft-format native document format (.pcraft bundle): manifest + content-addressed tiles
|
||||
│ ├─ io/ photocraft-io import/export orchestration; doc ⇄ PSD mapping; PDF/SVG import (features)
|
||||
@@ -148,10 +149,10 @@ photocraft/
|
||||
|
||||
1. A crate may depend only on crates in **lower** layers. No cycles and no sideways dependencies, except where listed.
|
||||
2. **Nothing below L6 may depend on any UI toolkit, winit, or a `platform` implementation.** Platform services reach the engine through traits defined in `engine` (or in `platform`'s trait-only core), and are injected at startup.
|
||||
3. **`photocraft-psd` depends on no workspace crate.** The doc ⇄ PSD mapping lives in `io`. This keeps the PSD crate publishable and reusable by other projects.
|
||||
3. **`photocraft-psd` depends on no workspace crate.** The doc ⇄ PSD mapping lives in `io`. This keeps the PSD crate publishable and reusable by other projects. The same holds for every standalone crate, with one documented exception: `codecs` → `heif` (both standalone and publishable; `STANDALONE_EXCEPTIONS` in `xtask/src/layers.rs`).
|
||||
4. **wasm gate:** every crate in L0–L5 (except feature-gated native backends) must build for `wasm32-unknown-unknown`. CI runs `cargo build -p photocraft-engine --target wasm32-unknown-unknown --no-default-features --features web`.
|
||||
5. `gpu` is optional for `engine`. Engine features are `gpu` (default on) and `cpu-only`, and `cpu-only` builds are what the headless CLI and CI tests use.
|
||||
6. **C dependencies** (libheif, optional LibRaw, pdfium) only behind features, only in `codecs`, `raw` or `io`, and never on by default for the web target.
|
||||
6. **C dependencies** (optional LibRaw, pdfium) only behind features, only in `codecs`, `raw` or `io`, and never on by default for the web target.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+4
-1
@@ -325,12 +325,13 @@ against committed **sha256 manifests**. All pins are in one place:
|
||||
| `corpus/photoshop/` | 256 PSDs we authored with Photoshop: smart filters, layer-style effect shapes, the text engine, adjustments in every mode and depth | https://github.com/storytold/photocraft-corpus (ours, MIT OR Apache-2.0) | `xtask/photoshop-corpus.sha256` |
|
||||
| `corpus/psd/` | 170 small psd-tools and ag-psd files, the mix most PSD tests use | psd-tools and ag-psd upstreams (MIT) | `xtask/psd-corpus.sha256` |
|
||||
| `corpus/psd-tools/` | the complete psd-tools test set (309 files) | psd-tools upstream (MIT) | `xtask/psd-tools-corpus.sha256` |
|
||||
| `corpus/heif/` | 9 small HEIC/HEIF files (checkerboards, RGB strips, a grid-tiled photo with EXIF/XMP, each with Apple's decode as `.ref.png`; a 10-bit RGBA file with its source PNG), for the `heif` feature | heic-rs (MIT OR Apache-2.0) and pillow-heif (BSD-3-Clause) upstreams | `xtask/heif-corpus.sha256` |
|
||||
| `corpus/pngsuite/` | PngSuite | schaik.com release archive (public domain) | (fixed archive) |
|
||||
|
||||
```sh
|
||||
cargo xtask corpus # where each corpus lives, its pin, present or missing
|
||||
cargo xtask corpus --all # fetch everything missing or stale (cold: about 15 s; verified copies are left alone)
|
||||
cargo xtask test-corpus # fetch, then cargo test --release --features corpus on psd, codecs, io, engine
|
||||
cargo xtask test-corpus # fetch, then cargo test --release --features corpus (+ heif on codecs, io) on psd, codecs, io, engine
|
||||
cargo xtask test-corpus -p io # narrow to one crate (repeat -p for more)
|
||||
cargo xtask test-corpus --changed # only if psd, io, codecs, compose, gpu, text or format changed vs origin/main
|
||||
cargo xtask test-corpus -- --nocapture # pass arguments to the test binaries (per-file tables)
|
||||
@@ -341,6 +342,8 @@ scripts/fetch-corpus.sh # the same as cargo xtask corpus --all
|
||||
|
||||
- The corpus tests sit behind the `corpus` cargo feature of `photocraft-psd`, `photocraft-codecs`,
|
||||
`photocraft-io` and `photocraft-engine`, so plain `cargo test` neither compiles nor needs them.
|
||||
The HEIF ones also need the `heif` feature of `photocraft-codecs`/`photocraft-io` (test-corpus
|
||||
turns it on).
|
||||
- With the feature on, a missing corpus is a failure ("run `cargo xtask corpus --all`"), never a
|
||||
silent skip, and every floor is enforced.
|
||||
- If you touch psd, io, codecs, compose, gpu, text or format, run `cargo xtask test-corpus` before
|
||||
|
||||
+2
-2
@@ -12,7 +12,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js
|
||||
| [Workspace and UI](#workspace-and-ui) | 0 | 5 | 18 | settings that do nothing: 60 of 135 (target 0) |
|
||||
| [Type](#type) | 0 | 3 | 6 | |
|
||||
| [Automation](#automation) | 1 | 3 | 7 | 626 of 626 menu items live (100.0%) |
|
||||
| [Reliability](#reliability) | 3 | 1 | 3 | never-crash lints on 26 of 26 crates; panic_hunt present |
|
||||
| [Reliability](#reliability) | 3 | 1 | 3 | never-crash lints on 27 of 27 crates; panic_hunt present |
|
||||
| [Distribution](#distribution) | 4 | 4 | 3 | |
|
||||
| [Open bugs](#open-bugs) | 6 | 3 | 23 | |
|
||||
|
||||
@@ -218,7 +218,7 @@ Menu wiring (`docs/parity.md`): 626 of 626 menu items live (100.0%). This counts
|
||||
|
||||
## Reliability
|
||||
|
||||
Never-crash attribute on 26 of 26 crate roots.
|
||||
Never-crash attribute on 27 of 27 crate roots.
|
||||
|
||||
| Id | Target | Status | Issue | Note |
|
||||
|---|---|---|---|---|
|
||||
|
||||
@@ -47,7 +47,7 @@ echo "==> PhotoCraft $VERSION for FreeBSD $ARCH"
|
||||
# The release VM has 12 GB; full parallelism on the biggest crates runs it out of memory.
|
||||
export CARGO_BUILD_JOBS="${CARGO_BUILD_JOBS:-4}"
|
||||
if [ "$SKIP_BUILD" = 0 ]; then
|
||||
(cd "$ROOT" && cargo build --release --locked -p photocraft -p photocraft-cli)
|
||||
(cd "$ROOT" && cargo build --release --locked -p photocraft -p photocraft-cli --features heif)
|
||||
fi
|
||||
WORK="$CARGO_TARGET_DIR/freebsd-package"
|
||||
STAGE="$WORK/$BASENAME"
|
||||
|
||||
@@ -44,7 +44,7 @@ modules:
|
||||
CARGO_HOME: /run/build/photocraft/cargo
|
||||
CARGO_NET_OFFLINE: 'true'
|
||||
build-commands:
|
||||
- cargo --offline build --release --locked -p photocraft
|
||||
- cargo --offline build --release --locked -p photocraft --features heif
|
||||
- install -Dm755 target/release/photocraft /app/bin/photocraft
|
||||
- install -Dm644 packaging/linux/ai.storyteller.photocraft.desktop /app/share/applications/ai.storyteller.photocraft.desktop
|
||||
- install -Dm644 packaging/linux/ai.storyteller.photocraft.mime.xml /app/share/mime/packages/ai.storyteller.photocraft.xml
|
||||
|
||||
@@ -42,7 +42,7 @@ BASENAME="photocraft-$VERSION-linux-$ARCH"
|
||||
echo "==> PhotoCraft $VERSION for Linux $ARCH ($FORMATS)"
|
||||
|
||||
if [ "$SKIP_BUILD" = 0 ]; then
|
||||
(cd "$ROOT" && cargo build --release --locked -p photocraft -p photocraft-cli)
|
||||
(cd "$ROOT" && cargo build --release --locked -p photocraft -p photocraft-cli --features heif)
|
||||
fi
|
||||
BIN="$CARGO_TARGET_DIR/release"
|
||||
WORK="$CARGO_TARGET_DIR/linux-package"
|
||||
|
||||
@@ -58,7 +58,7 @@ echo "==> PhotoCraft $VERSION for macOS ($ARCH), identity: $IDENTITY, notarize:
|
||||
if [ "$SKIP_BUILD" = 0 ]; then
|
||||
args=()
|
||||
for t in "${TARGETS[@]}"; do args+=(--target "$t"); done
|
||||
(cd "$ROOT" && cargo build --release --locked -p photocraft -p photocraft-cli "${args[@]}")
|
||||
(cd "$ROOT" && cargo build --release --locked -p photocraft -p photocraft-cli --features heif "${args[@]}")
|
||||
fi
|
||||
|
||||
rm -rf "$WORK"
|
||||
|
||||
@@ -62,7 +62,7 @@ if (-not $SkipBuild) {
|
||||
[Environment]::SetEnvironmentVariable($flagVar, '-C target-feature=+crt-static')
|
||||
# Fail the build (rather than warn) if the icon/VERSIONINFO can't be embedded.
|
||||
$env:PHOTOCRAFT_REQUIRE_WINRES = '1'
|
||||
Invoke-Native "cargo build ($Target)" { cargo build --release --locked -p photocraft -p photocraft-cli --target $Target }
|
||||
Invoke-Native "cargo build ($Target)" { cargo build --release --locked -p photocraft -p photocraft-cli --features heif --target $Target }
|
||||
}
|
||||
|
||||
$Bin = Join-Path $TargetDir "$Target\release"
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
f24a3b6ae495605e179bd34ae282047dcc4581819eb53041db34c9fdb66648cd heic-rs/checker-1024.heic
|
||||
26b302d18d5c0e932a69092dafe4ac25d63814514c63718a08325fd84b38d5d4 heic-rs/checker-1024.ref.png
|
||||
772a9b8fab3022ea95eb0b1d069053448f9602fad7829b33506877039a6af2c5 heic-rs/checker-64.heic
|
||||
aa89bda71d2359691d32362923967ff4839e8a4f7d54641b10c647c9fbf9aa2c heic-rs/checker-64.ref.png
|
||||
6e65bccd51ff2effdfce99ac4275b9f79c85bba51b5875d7fa455e1412821c25 heic-rs/rgb-strips-96.heic
|
||||
b3a619b185d4cdfb1aafb419fec4f054d86853bb6a65e5d044527e84579407bb heic-rs/rgb-strips-96.ref.png
|
||||
9ad0c62a8e044f0ce0a342153a6da3f360c688b88bd5287a2c8fbdba3055fbcb heic-rs/with-exif.heic
|
||||
0499c3a3d8aa1c4383494f90cc127a693f036ab6228f0b91ddcf96f662520129 pillow-heif/heif/RGBA_10__29x100.heif
|
||||
43960a48f77ffd19343dd38831cf9c4d25290edeeef1d4b35d57b0aa538b0734 pillow-heif/non_heif/RGBA_16__29x100.png
|
||||
+19
-6
@@ -4,17 +4,20 @@
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
|
||||
use crate::corpus_pins::{self, AG_PSD_COMMIT, PHOTOCRAFT_CORPUS_COMMIT, PNGSUITE_URL, PSD_TOOLS_COMMIT};
|
||||
use crate::corpus_pins::{self, AG_PSD_COMMIT, HEIC_RS_COMMIT, PHOTOCRAFT_CORPUS_COMMIT, PILLOW_HEIF_COMMIT, PNGSUITE_URL, PSD_TOOLS_COMMIT};
|
||||
use crate::pinned::USER_AGENT;
|
||||
use crate::{cargo, root, run};
|
||||
|
||||
/// Crates with corpus tests (behind their `corpus` feature).
|
||||
pub const CORPUS_CRATES: &[&str] = &["photocraft-psd", "photocraft-codecs", "photocraft-io", "photocraft-engine"];
|
||||
|
||||
/// Paths whose changes make `test-corpus --changed` run (the file-format and rendering crates).
|
||||
const CRITICAL: &[&str] = &["crates/psd/", "crates/io/", "crates/codecs/", "crates/compose/", "crates/gpu/", "crates/text/", "crates/format/"];
|
||||
/// Corpus crates with a `heif` feature: test-corpus enables it so the HEIF corpus tests run.
|
||||
const HEIF_CRATES: &[&str] = &["photocraft-codecs", "photocraft-io"];
|
||||
|
||||
/// `cargo xtask corpus [--all | --pngsuite | --download | --psd | --psd-tools | --photoshop [--local]] [--update-manifest]`
|
||||
/// Paths whose changes make `test-corpus --changed` run (the file-format and rendering crates).
|
||||
const CRITICAL: &[&str] = &["crates/psd/", "crates/io/", "crates/codecs/", "crates/heif/", "crates/compose/", "crates/gpu/", "crates/text/", "crates/format/"];
|
||||
|
||||
/// `cargo xtask corpus [--all | --pngsuite | --download | --psd | --psd-tools | --heif | --photoshop [--local]] [--update-manifest]`
|
||||
pub fn cmd(args: &[&str]) -> Result<(), String> {
|
||||
let update = args.contains(&"--update-manifest");
|
||||
let mut did = false;
|
||||
@@ -33,6 +36,10 @@ pub fn cmd(args: &[&str]) -> Result<(), String> {
|
||||
corpus_pins::PSD_TOOLS.fetch(update)?;
|
||||
did = true;
|
||||
}
|
||||
if args.contains(&"--heif") {
|
||||
corpus_pins::HEIF.fetch(update)?;
|
||||
did = true;
|
||||
}
|
||||
if args.contains(&"--photoshop") {
|
||||
if args.contains(&"--local") {
|
||||
corpus_pins::PHOTOSHOP.fetch_local(&local_clone()?, update)?;
|
||||
@@ -96,6 +103,8 @@ fn list() {
|
||||
ag-psd@{}, manifest xtask/psd-corpus.sha256. Fetch: --psd
|
||||
corpus/psd-tools/ [{}] the full psd-tools test set (MIT) at {PSD_TOOLS_COMMIT}
|
||||
manifest xtask/psd-tools-corpus.sha256. Fetch: --psd-tools
|
||||
corpus/heif/ [{}] a few HEIC/HEIF files from heic-rs@{} (MIT OR Apache-2.0) and
|
||||
pillow-heif@{} (BSD-3-Clause), manifest xtask/heif-corpus.sha256. Fetch: --heif
|
||||
corpus/pngsuite/ [{}] PngSuite (public domain), {PNGSUITE_URL}. Fetch: --pngsuite
|
||||
corpus/tiff/, corpus/exr/, corpus/raw/ optional, copied in by hand
|
||||
|
||||
@@ -106,6 +115,9 @@ Pins: xtask/src/corpus_pins.rs. Moving one: change it, then --<name> --update-ma
|
||||
&PSD_TOOLS_COMMIT[..12],
|
||||
&AG_PSD_COMMIT[..12],
|
||||
status(corpus_pins::PSD_TOOLS.is_current()),
|
||||
status(corpus_pins::HEIF.is_current()),
|
||||
&HEIC_RS_COMMIT[..12],
|
||||
&PILLOW_HEIF_COMMIT[..12],
|
||||
status(png_ok),
|
||||
);
|
||||
}
|
||||
@@ -186,10 +198,11 @@ pub fn test_cmd(args: &[&str]) -> Result<(), String> {
|
||||
for k in &crates {
|
||||
c.args(["-p", k]);
|
||||
}
|
||||
let features: Vec<String> = crates.iter().map(|k| format!("{k}/corpus")).collect();
|
||||
let mut features: Vec<String> = crates.iter().map(|k| format!("{k}/corpus")).collect();
|
||||
features.extend(crates.iter().filter(|k| HEIF_CRATES.contains(&k.as_str())).map(|k| format!("{k}/heif")));
|
||||
c.args(["--features", &features.join(",")]);
|
||||
if !passthrough.is_empty() {
|
||||
c.arg("--").args(passthrough);
|
||||
}
|
||||
run(c, &format!("cargo test --release --features corpus ({})", crates.join(", ")))
|
||||
run(c, &format!("cargo test --release --features corpus,heif ({})", crates.join(", ")))
|
||||
}
|
||||
@@ -18,6 +18,11 @@ pub const PHOTOCRAFT_CORPUS_COMMIT: &str = "f5b1178cab15309e05b7b504545df3c701f6
|
||||
pub const PSD_TOOLS_COMMIT: &str = "96eb134c17b2c65edf4c4151c0f00b802ada86c2";
|
||||
/// https://github.com/Agamnentzar/ag-psd (MIT): `test/` (master, 2026-07-02).
|
||||
pub const AG_PSD_COMMIT: &str = "387049670cb89b88fb8fe1b7c01aeacf98dd2e3b";
|
||||
/// https://github.com/tbraun96/heic-rs (MIT OR Apache-2.0): `tests/fixtures` at the v0.1.1 tag, the
|
||||
/// version photocraft-heif pins (2026-09-12).
|
||||
pub const HEIC_RS_COMMIT: &str = "4f0d4df474c773dfc3b14fa80e7219be6898866e";
|
||||
/// https://github.com/bigcat88/pillow_heif (BSD-3-Clause): `tests/images` (master, 2026-09-25).
|
||||
pub const PILLOW_HEIF_COMMIT: &str = "b16be1196dfa465a342d68894696e685ca3655cb";
|
||||
/// PngSuite (public domain), a fixed release archive.
|
||||
pub const PNGSUITE_URL: &str = "http://www.schaik.com/pngsuite/PngSuite-2017jul19.tgz";
|
||||
|
||||
@@ -92,5 +97,42 @@ pub const PSD_MIXED: PinnedCorpus = PinnedCorpus {
|
||||
},
|
||||
};
|
||||
|
||||
/// `corpus/heif`: a few small HEIC/HEIF files for the `heif` feature's tests. heic-rs's
|
||||
/// checkerboards, RGB strips and a grid-tiled photo with EXIF and XMP (synthetic pixels encoded by
|
||||
/// macOS `sips`, each `.ref.png` Apple's own decode), and pillow-heif's 10-bit RGBA file with the
|
||||
/// 16-bit PNG it was encoded from. The manifest selects the files.
|
||||
pub const HEIF: PinnedCorpus = PinnedCorpus {
|
||||
name: "heif",
|
||||
dest: "heif",
|
||||
upstreams: &[
|
||||
Upstream {
|
||||
prefix: "heic-rs/",
|
||||
repo: "tbraun96/heic-rs",
|
||||
commit: HEIC_RS_COMMIT,
|
||||
subdir: "tests/fixtures",
|
||||
extras: &[("LICENSE-MIT", "heic-rs/LICENSE-MIT"), ("LICENSE-APACHE", "heic-rs/LICENSE-APACHE")],
|
||||
},
|
||||
Upstream {
|
||||
prefix: "pillow-heif/",
|
||||
repo: "bigcat88/pillow_heif",
|
||||
commit: PILLOW_HEIF_COMMIT,
|
||||
subdir: "tests/images",
|
||||
extras: &[("LICENSE.txt", "pillow-heif/LICENSE.txt")],
|
||||
},
|
||||
],
|
||||
manifest: "xtask/heif-corpus.sha256",
|
||||
exts: &["heic", "heif", "png"],
|
||||
subset: true,
|
||||
sources_md: |c| {
|
||||
format!(
|
||||
"# HEIF test corpus\n\nSmall files selected from https://github.com/tbraun96/heic-rs/tree/{HEIC_RS_COMMIT}/tests/fixtures \
|
||||
(`heic-rs/`, MIT OR Apache-2.0, Thomas Braun) and https://github.com/bigcat88/pillow_heif/tree/{PILLOW_HEIF_COMMIT}/tests/images \
|
||||
(`pillow-heif/`, BSD-3-Clause, Pillow-Heif contributors), unmodified; licences next to them. Selected by and sha256-verified \
|
||||
against `{}` by `cargo xtask corpus --heif`. Gitignored; never commit these files.\n",
|
||||
c.manifest
|
||||
)
|
||||
},
|
||||
};
|
||||
|
||||
/// Every pinned corpus, in fetch order.
|
||||
pub const ALL: &[&PinnedCorpus] = &[&PSD_MIXED, &PSD_TOOLS, &PHOTOSHOP];
|
||||
pub const ALL: &[&PinnedCorpus] = &[&PSD_MIXED, &PSD_TOOLS, &PHOTOSHOP, &HEIF];
|
||||
@@ -39,6 +39,8 @@ pub const TABLE: &[(&str, Class)] = &[
|
||||
("raster", Class::Layer(0)),
|
||||
("psd", Class::Standalone),
|
||||
("codecs", Class::Standalone),
|
||||
// Optional HEIF/HEIC decoder (heic-rs), used only by `codecs` behind its `heif` feature.
|
||||
("heif", Class::Standalone),
|
||||
("raw", Class::Standalone),
|
||||
("adobe-assets", Class::Standalone),
|
||||
// Pen tablet input (the one isolated `unsafe` helper: AppKit interop on macOS).
|
||||
@@ -83,6 +85,16 @@ fn intra_layer_allowed(from: &str, to: &str) -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
/// The only workspace dependencies a standalone crate may have: (from, to), both standalone and
|
||||
/// both publishable. `codecs` uses the optional `heif` decoder crate behind its `heif` feature, so
|
||||
/// distributors can leave HEVC decoding out of a build; `heif` itself depends on no workspace crate.
|
||||
pub const STANDALONE_EXCEPTIONS: &[(&str, &str)] = &[("codecs", "heif")];
|
||||
|
||||
fn standalone_exception(from: &str, to: &str) -> bool {
|
||||
let (from, to) = (short_name(from), short_name(to));
|
||||
classify(to) == Some(Class::Standalone) && STANDALONE_EXCEPTIONS.iter().any(|(f, t)| *f == from && *t == to)
|
||||
}
|
||||
|
||||
/// External crates that constitute a UI toolkit / windowing dependency.
|
||||
/// Entries ending in `*` are prefixes.
|
||||
pub const UI_CRATES: &[&str] = &["egui", "eframe", "winit", "egui_kittest", "rfd", "bevy*"];
|
||||
@@ -177,6 +189,9 @@ pub fn check(crates: &[Crate]) -> Vec<Violation> {
|
||||
}
|
||||
if d.workspace {
|
||||
if class == Class::Standalone {
|
||||
if standalone_exception(&c.name, &d.name) {
|
||||
continue;
|
||||
}
|
||||
out.push(Violation::StandaloneHasWorkspaceDep { krate: c.name.clone(), dep: d.name.clone() });
|
||||
continue;
|
||||
}
|
||||
@@ -306,6 +321,20 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn standalone_exception_is_exactly_codecs_to_heif() {
|
||||
assert!(check(&[c("photocraft-codecs", &[("photocraft-heif", Normal, true)])]).is_empty());
|
||||
assert!(check(&[c("photocraft-codecs", &[("photocraft-heif", Dev, true)])]).is_empty());
|
||||
// Not the other way round, not for other standalone crates, and heif stays dependency-free.
|
||||
for (from, to) in [("photocraft-heif", "photocraft-codecs"), ("photocraft-psd", "photocraft-heif"), ("photocraft-heif", "photocraft-geom")] {
|
||||
let v = check(&[c(from, &[(to, Normal, true)])]);
|
||||
assert!(matches!(v[..], [Violation::StandaloneHasWorkspaceDep { .. }]), "{from} -> {to}");
|
||||
}
|
||||
// The exception does not open codecs up to layered crates.
|
||||
let v = check(&[c("photocraft-codecs", &[("photocraft-geom", Normal, true)])]);
|
||||
assert!(matches!(v[..], [Violation::StandaloneHasWorkspaceDep { .. }]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn standalone_is_usable_from_higher_layers() {
|
||||
assert!(check(&[c("photocraft-io", &[("photocraft-psd", Normal, true)])]).is_empty());
|
||||
|
||||
@@ -152,6 +152,10 @@ fn wasm_set() -> Result<Vec<String>, String> {
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Optional features that official builds enable and the web app ships, also checked for wasm32:
|
||||
/// (package, feature).
|
||||
const WASM_FEATURES: &[(&str, &str)] = &[("photocraft-codecs", "heif")];
|
||||
|
||||
fn cmd_wasm() -> Result<(), String> {
|
||||
let set = wasm_set()?;
|
||||
let mut results = Vec::new();
|
||||
@@ -161,6 +165,12 @@ fn cmd_wasm() -> Result<(), String> {
|
||||
let ok = run(c, &format!("cargo check --target wasm32-unknown-unknown -p {pkg}")).is_ok();
|
||||
results.push((pkg.clone(), ok));
|
||||
}
|
||||
for (pkg, feature) in WASM_FEATURES {
|
||||
let mut c = cargo();
|
||||
c.args(["check", "--target", "wasm32-unknown-unknown", "-p", pkg, "--features", feature]);
|
||||
let ok = run(c, &format!("cargo check --target wasm32-unknown-unknown -p {pkg} --features {feature}")).is_ok();
|
||||
results.push((format!("{pkg} --features {feature}"), ok));
|
||||
}
|
||||
println!("\nwasm32-unknown-unknown check:");
|
||||
for (p, ok) in &results {
|
||||
println!(" {:<6} {p}", if *ok { "ok" } else { "FAIL" });
|
||||
|
||||
Reference in new issue
Block a user