Open HEIC/HEIF photos (#355) (#372)

* 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:
authored and GitHub committed 2026-10-07 10:20:40 -04:00
1 parent 7055f0c51b
commit 47f9306fd0
43 files changed
+960 -50

No files matched your search

+11 -3
View File
@@ -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
+2 -2
View File
@@ -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
+1
View File
@@ -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
View File
@@ -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"
+1 -1
View File
@@ -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
View File
@@ -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
+5
View File
@@ -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
+1 -1
View File
@@ -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; }
+1 -1
View File
@@ -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";
+5
View File
@@ -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
+1 -1
View File
@@ -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 -1
View File
@@ -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.
+5
View File
@@ -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
View File
@@ -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`.
+10 -1
View File
@@ -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 });
});
+103
View File
@@ -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
View File
@@ -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
View File
@@ -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 {
+8 -3
View File
@@ -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),
}
}
+23
View File
@@ -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 {
+194
View File
@@ -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)));
}
+12 -3
View File
@@ -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());
}
+2 -2
View File
@@ -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 {
+19
View File
@@ -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
+34
View File
@@ -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`).
+218
View File
@@ -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"));
}
}
+2
View File
@@ -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 }
+65
View File
@@ -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}");
}
}
+15
View File
@@ -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}");
}
}
+5 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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 |
|---|---|---|---|---|
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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"
+9
View File
@@ -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
View File
@@ -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(", ")))
}
+43 -1
View File
@@ -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];
+29
View File
@@ -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());
+10
View File
@@ -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" });