mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-28 03:47:00 -06: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>
66 lines
2.7 KiB
Rust
66 lines
2.7 KiB
Rust
//! 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}");
|
||
}
|
||
}
|