Files
photocraft/crates/io/tests/heif.rs
47f9306fd0 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>
2026-10-07 10:20:40 -04:00

66 lines
2.7 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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}");
}
}