Commit Graph
27 Commits
Author SHA1 Message Date
a35af4e279 Add FITS read/write (astronomy images) (#2956)
* Add FITS read/write codec

FITS (the astronomy format) opens and saves as a flat image: the primary
HDU or first IMAGE extension; gray, or RGB when NAXIS3 = 3; BITPIX 8/16/
32/64/-32/-64 with BSCALE/BZERO/BLANK; rows follow ROWORDER (bottom-up by
default). Camera U16 data stays exact; float data is normalised to 0..1.
Header cards round-trip as text metadata (HIERARCH for long names).
Tile-compressed (fpack) files are refused as unsupported.

Wired into File > Open, Save As and the batch commands as .fits/.fit/.fts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Test FITS open and save through photocraft-io

Gray 16-bit, RGB 8-bit and RGB 32-bit float documents round-trip exactly,
and header cards travel as the document's text metadata.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* FITS: hostile-header regression test (absurd axes, bad BITPIX, no END, HDU chains, overflowing sizes)

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-11 13:15:22 -04:00
e667483820 fix(codecs): exports drop the source's EXIF thumbnail (#2147, #2373) (#2450)
Exports copied the source EXIF with only Orientation and resolution
patched, so a camera JPEG's embedded thumbnail (IFD1) came along. Finder,
Android and upload pickers show that thumbnail instead of decoding the
image, so an edited photo kept its original preview.

export_exif now clears IFD0's next-IFD pointer, unlinking IFD1. Nothing
else moves, so every other offset stays valid; JPEG, PNG, WebP and the PSD
EXIF resource all go through it.

Co-authored-by: vivekprojects-GIT <179340152+vivekprojects-GIT@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 13:11:59 -04:00
291f30da55 EXR: Cryptomatte layers list, decode and hash to the specification (#2180)
* EXR: Cryptomatte layers list, decode and hash to the specification

Cryptomatte (specification 1.2) carries per-pixel object and material
IDs with coverage in <layer>NN.red/.green channel pairs, announced by
cryptomatte/<key>/name|hash|conversion|manifest header attributes - the
ID channels Arnold, V-Ray, Redshift, Mantra/Karma and Cycles write next
to the beauty pass.

cryptomatte_layers lists every layer from the headers alone (name, key,
channels, the embedded manifest parsed and capped at 16 MiB, a sidecar
pointer); decode_cryptomatte returns the per-pixel (ID, coverage) pairs
above zero, sorted by descending coverage; cryptomatte_id hashes names
the reference way (MurmurHash3 x86-32, the exponent fixed away from
denormals/inf/NaN, read as f32 bits) - verified against the official
test vectors - and cryptomatte_key/cryptomatte_preview_color expose the
metadata key and preview colour. Files with deep parts are refused, like
the other flat part decoders.

* chore(ui): format prefs_ui (the workspace fmt gate is red on main until #2417 lands)

* codecs: Cryptomatte decode checks the layer size without truncating casts

Count each pixel's pair list in the allocation check too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 06:40:49 -04:00
f9ea13a857 EXR: multi-part files open the colour part; exr_info lists them all (#2116)
* EXR: multi-part files open the colour part; exr_info lists them all

A Maya/Arnold render writes one part per AOV. The decoder took part 0
silently, so a depth-only part failed the whole file with "no R/G/B or
Y channels". Now the part that looks most like a colour image opens
(RGBA > RGB > YA > a single channel, earliest wins), with a
DecodeWarning::MoreParts naming how many parts exist.

exr_info lists every part (name, view, size, channels, deep/tiled) from
the headers alone, and decode_exr_part decodes a chosen part — F16 parts
stay F16. Deep parts stay with the deep decoder: a file that has any is
refused here with a pointer to decode_deep_exr, by design.

* EXR: cap the parts' sizes together, move the chosen part out instead of cloning it

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 18:51:33 -04:00
Gert Pellin - GPE 58289b2822 fix(codecs): read the resolution from EXIF, XMP and Photoshop APP13; write it consistently (#1691) (#1744)
A camera JPEG (e.g. Nikon D3200) records its resolution only in EXIF IFD0, so it
opened at 72 ppi instead of 300, and saving wrote JFIF 72 next to the source's
EXIF 300.

Reading: a JPEG takes the first usable of APP13 Photoshop ResolutionInfo
(0x03ED), XMP tiff:X/YResolution, EXIF IFD0 X/YResolution + ResolutionUnit
(2 inch, also when absent; 3 cm), and only then the JFIF density. That is the
order Photoshop resolves them in; JFIF comes last because cameras write EXIF
only while many tools write a default JFIF 72. Other formats (WebP, HEIF, PNG
without pHYs, ...) fall back to XMP then EXIF when they have no resolution of
their own. All parsing is bounds-checked; malformed data reads as no
resolution.

Writing: JPEG, PNG, WebP, TIFF (XMP), PSD and Save for Web JPEG rewrite the
EXIF/XMP resolution to the image's: RATIONAL values in place, ResolutionUnit 2,
and an entry that can't be rewritten safely is removed from IFD0 without moving
any other data.
2026-10-09 22:07:59 +07:00
Elvis33LEandClaude Opus 5.5 7533254791 Keep PNG text metadata through document saves (#1684)
PNG tEXt/zTXt/iTXt entries (and TIFF ASCII tags) were dropped on open with a warning. The
document now keeps them in Metadata.text, the .pcraft manifest saves them (older files still
load), and PNG/TIFF saves write them back; Metadata: None and embed_metadata=false strip them,
and PSD/PSB and palette PNG exports warn that they are dropped. XMP in a tEXt or zTXt chunk is
read as XMP too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:53:37 -04:00
David MannandClaude Opus 5.5 284724af37 TIFF: alpha after an unspecified extra sample opens transparent (#1349) (#1513)
TIFF 6.0 allows the alpha at any ExtraSamples position, but the decoder
only looked at the first extra sample, and for RGB/CMYK the tiff crate
drops every sample after its colour type. With ExtraSamples [0, 2] the
alpha was silently lost (and gray [0, 2] opened as plain gray).

The alpha is now the first extra sample marked associated (1) or
unassociated (2), wherever it is. When it is not the first extra sample
of an RGB or CMYK image, the crate is asked for every sample (the
photometric is patched to multiband gray, as for WhiteIsZero) and the
colour and alpha samples are picked per chunk; planar files read the
alpha's own plane. Associated alpha in that position is made straight.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 11:12:00 +07:00
StreetblockandGPT-6 7fd610741b Fix PNG text metadata decode budget bypass (#1460)
Bound decoded PNG text and XMP by one shared max_alloc budget before
growing output, retaining CRC validation and malformed ancillary handling.
Cover compressed, plain, Unicode and post-IDAT metadata with small
synthetic regression fixtures, and document the budget's scope.

Opt Streetblock into public GitHub profile-name credits at their request.

Co-authored-by: GPT-6 (Codex) <noreply@openai.com>
2026-10-09 09:02:07 +07:00
kayrockscreenprintingandClaude Opus 5.5 9af9df8275 PNM: the file, not the header, bounds what a decode reserves (#1110) (#1365)
ASCII PGM/PPM reserved a u32 (four bytes) per declared sample before
reading any, against a Limits budget of one or two bytes per sample, so a
19-byte header could ask for 12 GiB. With Limits::none() a 2³¹ × 2³¹
header panics with "capacity overflow"; PBM reserved 2⁶² bytes before
finding the raster missing and aborted. ASCII reservations are now capped
by what the remaining bytes can hold (a PBM digit is at least a byte, a
PGM/PPM sample at least a digit and a separator), and binary PBM checks
the raster length before reserving.

Limits::none() documents that it is still bounded by usize overflow
checks, but the decoder multiplied w × h × channels, n × 2 (16-bit
samples) and the PFM raster length unchecked. A new sample_count checks
the product, and the length checks use checked_mul.

Tests: a header alone reserves nothing (P1-P4), u32::MAX sizes are errors
(P3, P5, P6, PF, P7), and ASCII files with no slack (PBM digits without
separators, no trailing whitespace) still decode. The first two failed
before the fix: an abort on the 2⁶² reservation, "capacity overflow" for
the P2/P3 headers, and "attempt to multiply with overflow".

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 07:39:45 +07:00
Tanish Manku 964a41dc02 Corpus: pin the deep OpenEXR test images and check them in CI (#881)
The deep EXR decoder (#642) was verified against the OpenEXR project's deep test images, but those
lived in a hand-copied `corpus/exr/` and the test skipped when they were absent, so CI never read
them. `cargo xtask corpus --exr` (and `--all`) now fetches the five `*.deep.exr` files of
openexr v3.5.2 `src/test/bin/test_images`, sha256-verified against `xtask/exr-corpus.sha256`.

`real_deep_files_decode` is behind the `corpus` feature and fails when the corpus is missing. It
checks each file's sample total, largest per-pixel count, cumulative counts and channel lengths,
and that the decoded u32 ID channels hold exactly the IDs listed by the upstream `.txt` sidecars
(an independent oracle), plus the flattened decode. FILE-216-3 moves to partial (EXR read in CI;
TIFF and RAW still are not).
2026-10-08 16:08:56 -04:00
96413cff45 Deep EXR: write the flattened pixel by layout, not a fixed RGBA quadruple (#1021)
* Deep EXR: write the flattened pixel by layout, not a fixed RGBA quadruple

Grayscale deep files (Y + A + Z) flattened wrong: the tail of the
composite loop wrote a fixed [R, G, B, A] quadruple, so a GrayA image's
alpha slot received an un-premultiplied luminance copy (alpha 1.0 for a
0.5-alpha file) while the real alpha fell past the end of the row. The
values are now written by layout: RGB(A), gray(+A), gray - with the
alpha in the slot it belongs to.

* Deep EXR flatten: no per-pixel Vec allocation

Build a fixed [f32; 4] plus a length instead; this runs once per pixel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 16:08:27 -04:00
ec7302f4b3 TIFF: BigTIFF, every IFD and SubIFD, banded parallel decode (FILE-215-8, part of #215) (#859)
* TIFF: BigTIFF, every IFD and SubIFD, banded parallel decode (FILE-215-8)

Large and multi-page TIFFs now open the way Photoshop opens them, quickly and without a second
full-size buffer, and any directory of the file can be opened on request.

* photocraft-codecs `tiff_ifd`: a bounds-checked directory walker for classic TIFF and BigTIFF
  (LONG8 / IFD8, both byte orders). It follows the whole IFD chain and the SubIFD trees (tag
  330) with a seen-set, a depth limit and a 10 000-directory cap, and checks every value range
  against the file. `tiff_info` lists the pages (size, kind: page / reduced-resolution / mask,
  parent, storage); `default_page` is the first full-resolution page of the main chain, so a
  leading thumbnail is skipped. `decode_tiff_page`, `tiff_orientation`, `tiff_page_orientation`.
* Banded decode: the `tiff` crate reads the chosen directory through a patched byte view (header
  -> that IFD, its next pointer -> 0, WhiteIsZero / palette -> BlackIsZero), so SubIFDs and
  cyclic chains decode. The image is allocated once (fallibly, zeroed in parallel) and each
  strip or row of tiles, across every plane, is decoded straight into it, in parallel on native
  targets, with one capped chunk-sized scratch buffer per worker. New: planar configuration 2
  (was an error), palette images (as RGB), 64-bit integers, associated alpha made straight,
  non-alpha extra samples dropped. Image data cut off by the end of the file decodes with a
  `Truncated` warning. Before the crate sees a file: structural tag values must lie inside it,
  the chunk tables must match the geometry (checked in 64 bits; the crate overflowed and
  panicked on `(h / rows + 1) * planes`), and a decompression-bomb guard refuses files far too
  small for their declared size.
* WhiteIsZero and BigTIFF Orientation keep upstream #746's behaviour (#691, #693) through the
  new decode path; regression tests cover both.
* Encode: `EncodeOptions::tiff_bigtiff` writes BigTIFF; without it, BigTIFF is chosen when the
  file could pass 4 GiB (and a classic write that overflows 32-bit offsets is redone as BigTIFF).
* photocraft-io: `import_tiff_page(name, bytes, page)`; the TIFF branch of import goes through it.
* Tests: codecs `tiff_pages.rs` on hand-built files (`tests/common/tiffgen.rs`): BigTIFF LE/BE at
  8/16/32f, padded tiles and planar strips/tiles, pages and SubIFDs, cycles, the IFD cap,
  truncated and past-EOF strips, huge dimensions / counts / tiles, WhiteIsZero, palette, wide
  formats, BigTIFF write, and a cut / bit-flip / random-mutation zoo (an ignored long run did
  200 000 mutations without a panic); io `tiff_pages.rs`. The `decode_tiff` fuzz target walks
  every page. `tiff_bench.rs` (ignored) times a 30 MP decode against the previous path.
* 30 MP RGB (release, 16 threads, same run): Deflate 344 -> 32 ms, LZW 428 -> 43 ms, 16-bit
  Deflate 497 -> 46 ms, uncompressed 15 -> 10 ms, 256 px tiles 36 -> 10 ms, planar error -> 12 ms.
* Scorecard FILE-215-8 done; README and roadmap updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* rustfmt the TIFF reader and tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: leon <claude.udder842@passmail.net>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-07 22:37:51 -04:00
735bb09a98 Lossy WebP: a pure-Rust VP8 encoder with quality and lossless controls (#648) (#880)
* Lossy WebP: a pure-Rust VP8 encoder with quality and lossless controls (#648)

crates/codecs gains codecs/vp8: a clean-room VP8 key-frame encoder from RFC 6386
(boolean coder, 16x16 and 4x4 intra prediction with per-macroblock mode decision,
forward DCT/WHT paired with the bit-exact inverse transforms, dc/ac quantiser tables,
token coding with per-frame probability updates, skipped macroblocks). webp.rs wraps
the frame in the RIFF container (bare VP8, or VP8X with ALPH alpha and ICCP/EXIF/XMP).
EncodeOptions: webp_lossless now picks the encoder instead of erroring, webp_quality
(0-100) sets the quantiser; a lossy WebP reports a LossyCompression fidelity warning.

Export As: the WebP entry gets Quality and a Lossless switch (new tl! string in all
catalogs). CLI --quality, MCP/RPC doc_save quality and the engine's JPEG-style
quality turn a WebP lossy; without a quality WebP stays lossless as before.

Tests: tests/webp_lossy.rs decodes every file with image-webp (PSNR by quality,
sizes fall with quality and are >= 4x below lossless on noisy photos, flat frames
under 1.2 KB, alpha exact, metadata round trip, odd/tiny sizes, 16-bit input, the
16383 px limit as an error, text edges), plus unit tests for the coder, transforms,
prediction and header.

Co-Authored-By: Claude Mythos 5.1 <noreply@anthropic.com>

* i18n(it): Lossless (Export As WebP switch) in the Italian catalog that landed on main

Co-Authored-By: Claude Mythos 5.1 <noreply@anthropic.com>

---------

Co-authored-by: leon <claude.udder842@passmail.net>
Co-authored-by: Claude Mythos 5.1 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-07 21:12:38 -04:00
bd632e7eb3 Layered TIFF: open and save Photoshop layer data in TIFF files, in either byte order (part of #215) (#672)
* Layered TIFF: open and save Photoshop layer data in TIFF files, in either byte order (part of #215)

Photoshop keeps a layered TIFF's layers in two private tags: 37724 (ImageSourceData, the same
layer section a PSD has) and 34377 (the image resources). PhotoCraft now reads and writes both,
so a TIFF opens with its groups, masks, layer styles, type, shapes, smart objects and adjustment
layers exactly as the PSD path would, and saves them back.

* photocraft-psd: a `tiff` module with `ImageSourceData` (parse/write, PSD- and PSB-sized),
  the 34377 resource helpers, and a field-by-field byte-order transcoder. In Intel-order TIFFs
  Photoshop byte-swaps the whole block structure (reversed keys, little-endian integers and
  doubles) while pixel samples stay big-endian; the transcoder knows the layer-info structure,
  masks, blending ranges and every block photocraft-io maps (descriptors, TySh, lrFX, the
  binary adjustments, paths, patterns, filter effects, metadata settings, linked layers,
  annotations...), recognizes descriptor-shaped unknown blocks, and drops the rest with a
  warning instead of passing byte-swapped data to other readers. The reader gained an optional
  little-endian mode. 23 unit tests; a fuzz target in the CI matrix.
* photocraft-codecs: the two tags travel as opaque bytes on `Metadata`, read by a
  bounds-checked walk of IFD0 (classic and BigTIFF, both byte orders) and written back as
  BYTE / UNDEFINED tags. `tiff_writes_little_endian()` tells which order the encoder produces.
* photocraft-io: `tiff_layers` rebuilds a PSD model from the TIFF's pixels plus the tags on
  import, and on export runs the PSD exporter (new `merged_matte` / `merged_compression`
  options), interleaves its merged planes for the TIFF image and stores the layer section in
  the encoder's byte order. `ExportOptions.tiff_layers` (default on). Lab and Multichannel
  documents save flat; extra alpha channels are not written (one transparency sample).
* UI: the TIFF Options prompt ("Including layers will increase file size." / "Discard Layers
  and Save a Copy" / "Don't show again") behind Preferences > File Handling > Ask Before
  Saving Layered TIFF Files; `ExportSettings.tiff_layers`; catalogs updated.
* Tests: io round trips at 8/16/32 bits in Gray/RGB/CMYK, equivalence with the PSD path, every
  TIFF compression, the flat fall-backs, malformed input, and files written by psdtags in both
  byte orders (fixtures from scripts/layered_tiff_fixtures.py); a corpus test (feature
  `corpus`) writes every corpus PSD as a layered TIFF and compares the result with the PSD
  round trip (700 files, 0 differences, 0 crashes). Codecs tests cover the tag walk on
  hand-made files.
* Scorecard FILE-215-7 done, BUG-204-3 partial; README and book updated.

Co-Authored-By: Claude Mythos 5.1 <noreply@anthropic.com>

* Layered TIFF: de and pt-br strings; neutral wording in the psd tiff docs

The four psdtags fixtures stay out of the repository (no binary test data here): the
generator now writes them to the gitignored corpus/layered-tiff/, and the tests that read
them skip with a message when absent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Regenerate docs/scorecard.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: leon <claude.udder842@passmail.net>
Co-authored-by: Claude Mythos 5.1 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-07 17:10:35 -04:00
a1f4387e63 fix(codecs): correct TIFF polarity and BigTIFF orientation (#746)
Use the TIFF decoder's normalized WhiteIsZero samples without a second inversion, and parse BigTIFF IFD0 Orientation so decode applies the recorded rotation.

Fixes #691

Fixes #693

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Co-authored-by: Tanish Manku <tmanku.dev@gmail.com>
Copilot-Session: e77cd34a-ce3e-4981-996f-4d37d49df90d
2026-10-07 13:50:04 -07:00
Tanish MankuandClaude Opus 5.5 f46c462129 rustfmt crates/codecs/tests/deep_exr.rs (Format red on main since #642) (#804)
Formatting only: cargo fmt --check (rustfmt 1.99, as CI) fails on main at
crates/codecs/tests/deep_exr.rs:360 since #642 landed.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 13:47:55 -07:00
107ffe9127 codecs: decode deep OpenEXR (deepscanline/deeptile), composited flat or structured (#642)
* codecs: decode deep OpenEXR (deepscanline/deeptile), composited flat or structured

Deep EXR files errored with "not supported: deep data not supported yet"
(the `exr` crate rejects deep headers, and its chunk framing for them is an
unfinished guess), so the chunk stream is walked directly from the bytes, per
the OpenEXR file-layout specification:

- offset tables after the headers; deep chunks of [y | tile coords, packed
  table size, packed data size, unpacked data size, compressed cumulative
  sample-count table, compressed sample data],
- NONE, RLE and ZIPS (zlib, the +128 difference predictor and the half-split
  interleave - applied to the count table and the sample data alike; a stored
  payload of uncompressed length is raw). ZIP-16 and mip/rip-map deep tiles
  decline with `unsupported`,
- samples are channel-grouped, per pixel; u32 counts cap the allocation and
  go through `Limits` before anything is held in memory.

`decode` composites to the flat image (samples sorted by Z, front-to-back
`over` with premultiplied colour, un-premultiplied to straight alpha) and
reports a new DecodeWarning::DeepFlattened; `decode_deep_exr` returns the
structured DeepImage (per-channel samples plus cumulative counts) for callers
that composite deep data themselves. exr::Error::NotSupported now maps to
CodecError::unsupported instead of malformed, like the TIFF codec.

Validated against the OpenEXR repository's deep test images (11/42/64/objectid
.deep.exr: 256x256, up to 8 samples/pixel, plausible z and alpha), and covered
by a synthetic generator (NONE/RLE/ZIPS x scanline/tile) with hand-computed
compositing oracles, truncation sweeps and hostile offsets/sizes (13 tests).

* Deep EXR: match the OpenEXR layout (RLE tokens and predictor, per-row tile tables and data), keep HDR

Checked against deep files written by the OpenEXR 3.5 library (scan line and tiled,
NONE/RLE/ZIPS): RLE tokens were inverted and skipped the predictor and byte split, the
count table of a tile restarts at every row, a tile's sample data is grouped per row, edge
tiles may store a full-size table, and the flattened colour was clamped to 1.0. The
synthetic writer in the tests now follows the same layout; new HDR / edge-tile test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 16:45:02 -04:00
ac5338360f JPEG export no longer fails when the document's XMP is over 64 KB (#645)
A JPEG holds XMP in one APP1 segment, at most 65,533 bytes. Documents
from the source application often carry far more: their XMP lists every
document ever placed in them (DocumentAncestors, thousands of ids) and
the text of every type layer (TextLayers). The encoder passed the packet
through whole, the segment was refused, and the export failed with "App
segment exceeds maximum allowed data length of 65533". That stopped
every JPEG path: Export As, Save a Copy, convert, Layers and Artboards to
Files, Batch and Image Processor. 3 of 16 real designer documents in a
sweep hit it.

When the packet doesn't fit, the encoder now leaves out those two
layered-document properties (any namespace prefix), which a flat image
doesn't need; everything else in the packet is kept. If it still
doesn't fit, the XMP is left out, and so is an EXIF block over 64 KB.
Both cases are reported ahead of time by a new fidelity warning,
MetadataTooLarge, so the export shows what was dropped. Formats without
a segment limit (PNG, TIFF, WebP) still keep the packet whole.

Test: jpeg_with_oversized_metadata_still_encodes (fails before: encode
error), at 8, 16 and 32 bits.

Co-authored-by: EthanSuquet <292885762+EthanSuquet@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 16:32:26 -04:00
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
BFlatandClaude Opus 5.5 8a8371395b Import warns when it keeps only the first frame or page, or a JPEG ends early (#518, #523) (#563)
Decoders now report what the decoded image doesn't show in a new
`Image::warnings` (`photocraft_codecs::DecodeWarning`), and `image_to_document`
turns them into import warnings, so they reach `photocraft-cli info` /
`convert` stderr, `doc.open` over MCP and serve, and the app's open notice
through the existing import-warnings path.

* #523: APNG (acTL frames, plus a default image outside the animation),
  animated GIF (image descriptors counted by walking the blocks), animated
  WebP and multi-page TIFF (IFDs walked without decoding; reduced-resolution
  copies and transparency masks are not pages) say "only the first of N
  frames/pages was imported". Single images stay quiet; the kept frame's
  pixels are unchanged.
* #518: a JPEG whose data runs out before its end-of-image marker (marker
  segments skipped by length, scan data by byte stuffing) still decodes
  leniently but warns "JPEG data ends early (the file is truncated or
  damaged); part of the image is missing". A JPEG that ends before any scan
  data is an error.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 16:20:02 +07:00
Brandon Thomas 0349b0c0f1 Harden EXIF orientation: limits after rotation, fallible allocation, strict IFD parsing (#345) (#345) 2026-10-06 18:21:33 -04:00
Brandon Thomas 7ee4bb2858 Apply EXIF orientation on open, write Orientation = 1 on export (#285) (#318) (#318) 2026-10-06 08:20:36 -04:00
Brandon Thomas 968baf639e Photoshop oracles via storytold/photocraft-corpus: pinned corpora, corpus feature, test-corpus, CI job (#190) (#190) 2026-10-05 17:47:33 -04:00
Brandon ThomasandClaude Opus 5.5 69f0e25d5f Never crash: psd, codecs, format, io are panic-free (Rule 9) (#34)
- codecs: bump tiff 0.10 -> 0.11. tiff 0.10 indexed an empty SampleFormat
  list and panicked on a crafted TIFF (found by mutation fuzzing);
  regression test tiff_empty_sample_format_is_an_error.
- codecs: Image::new returns Result instead of panicking on size overflow.
- format: par_compress returns an error if a compression worker panics.
- io: par_map joins every worker before relaying a worker panic, so the
  thread scope can't panic on an unjoined thread.
- All four crates now deny unwrap/expect/panic/unimplemented/todo/
  unreachable outside tests.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 13:25:17 -04:00
Brandon ThomasandClaude Opus 5.5 f7eb6ea9e7 Format: adopt the craft-family rustfmt.toml and format the workspace
CI's `cargo fmt --all -- --check` has failed on every run because the
repo had no rustfmt config and was never formatted. Use the same settings
as EffectCraft/FilmCraft (max_width 160, use_small_heuristics Max), which
also keeps the churn small. Formatting only; no code changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:31:17 -04:00
6bdee62e87 Codecs: camera raw files fail with a clear "not supported" error (#25)
Camera raw files are TIFF-structured, so they reached the TIFF decoder.
A raw image with the TIFF/EP CFA or DNG LinearRaw photometric
interpretation failed with "Tiff: unknown photometric interpretation",
a CR2 with "colour type YCbCr(8)", and a DNG or a raw whose sensor data
sits in a SubIFD behind an RGB IFD0 quietly opened that small preview
as if it were the photo.

The TIFF decoder now checks the structure first (CR2 signature,
DNGVersion tag, CFA / LinearRaw image in IFD0, its SubIFDs or the next
IFDs; bounds-checked header reads only) and returns Unsupported:
"camera raw files (such as CR2, NEF, ARW or DNG) are not supported".
Ordinary TIFFs decode as before. Tests use tiny synthetic TIFFs with
those markers, including truncations.

Co-authored-by: storybored379 <storybored379@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:13:19 -04:00
Brandon Thomas 16941f7198 one-shot 2026-09-30 05:02:08 -04:00