* TIFF: open YCbCr images as RGB (#10316)
Scanners often write JPEG-compressed YCbCr TIFFs with 2x2 chroma
subsampling. The tiff crate decodes and upsamples them but returns raw
YCbCr samples, which the decoder rejected with "colour type YCbCr(8)".
The new tiff/ycbcr.rs converts them to RGB after decoding: JPEG data
uses the JFIF full-range BT.601 conversion (as libtiff does), other
compressions honour ReferenceBlackWhite and YCbCrCoefficients with the
TIFF defaults as fallback. 8 and 16-bit. YCbCr with extra samples, or
in other sample formats, fails with a clear unsupported error.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Contributors: add Yuki-init's display name
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* TIFF YCbCr: test that YCbCrCoefficients set the luma weights
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* TIFF: YCbCr tags with the wrong value count use the defaults
tag_f64s now returns None unless the tag holds exactly n values, so a
YCbCrCoefficients or ReferenceBlackWhite tag with extra values falls
back to the TIFF defaults instead of being truncated.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lrgj5pnShYtsSnrsZq5iRn
* TIFF YCbCr: tests for tiled JPEG with ICC, big-endian 16-bit and planar
Covers the layouts the review asked for: a tiled 4:2:0 JPEG file with an
embedded profile (checked against an independent JPEG decoder), a
big-endian 16-bit file, and planar data in both byte orders.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lrgj5pnShYtsSnrsZq5iRn
* TIFF YCbCr: cite the formula sources and pin the green-channel choice
Documents where the conversion comes from: TIFF 6.0 Section 21
(YCbCrCoefficients, pp. 90-91) and libtiff's tif_color.c
(TIFFYCbCrToRGBInit, TIFFYCbCrtoRGB). Red and blue follow the spec;
green follows libtiff and adds Y unscaled, which matches the spec only
when the luma weights sum to 1. The module doc records this as a
deliberate compatibility choice, and a new test pins it: with weights
0.25/0.25/0.25, mid gray stays (128, 128, 128).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lrgj5pnShYtsSnrsZq5iRn
* Codecs README: YCbCr TIFFs with three 8- or 16-bit samples decode to RGB
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lrgj5pnShYtsSnrsZq5iRn
* Format tiff_ycbcr tests with rustfmt
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(tiff): compare grouped YCbCr decoding and update support notes
* style(tiff): align grouped decode call with test formatting
* style(tiff): apply repository rustfmt to grouped YCbCr regression
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
Co-authored-by: FredApps <FredApps@users.noreply.github.com>
Conflicts resolved as unions of both sides: codecs re-exports (PngRows + TiffBands), smart_cmds source imports
(main's source_surface_from_buffer and cache API with #8571's svg_source_image_in/convert_svg_source; main's
duplicate rendering import dropped), io flat.rs test modules (#8536's banded TIFF tests and main's PNG ones),
menus/invoke.rs (#8571's smart SVG intercept and main's layer.selectUnderCursor), state.rs (#8311's
type_box_overflow and main's type_insertion_style), the canvas_geom test keeps the is_finite guard, docs
status lines keep both change lists, i18n catalogs keep both sides' rows (hi.tsv: main's wording where both
added the same string).
UPDATE_APP (native builds; craft-update is a native-only dependency) carries our 128 px icon and accent (#2f7bf5, its colour in ArtCraft Launcher). The updater, the self-update
helper and craft-update's startup prompt all get them through crate::UPDATE_APP; the
prompt draws the icon, the accent and "by ArtCraft" (craft-update's default developer).
craft-update is pinned to storytold/craft-libs main at 1065653e7 (#434).
Clippy fixes for Rust 1.99 in untouched code (same logic):
- apps/photocraft/src/app_dirs.rs
- crates/codecs/src/codecs/via_image.rs
- crates/engine/src/canvas_geom/tests.rs
- crates/ui-egui/src/panels/blend_preview.rs
- The free-memory check ran for every flat TIFF on Linux with a 512 MiB
reserve, so small TIFFs that open on main were refused on a small VM or
in a container: it now runs only for sizes the whole-image decode
refused (over 2^30 px or its byte budget).
- In a cgroup, memory.current counts page cache the kernel reclaims
before memory.max is hit; its inactive_file part now counts as free.
A `docker --memory=2g` batch whose cache filled up refused every TIFF.
- A single-strip TIFF is one group, the whole image, and every converted
piece of it was held at once: conversion now runs in rounds of at most
32 MiB of source rows per core.
- TiffBands::open reported no ExtraSamplesDropped/OverLimit warnings on
32-bit targets (wasm); both decoders now share prepared_warnings, and
Truncated goes before MorePages by position.
- Drops the ar.tsv rows of "Fix CI after merging upstream": main added the
same two keys (#10173), so the merged catalogue had duplicates and
i18n::tests::bundled_catalogs_are_consistent failed.
- Doc comments: the swap cap is every cgroup's above too (the ancestor
walk FredApps asked for); a cut-off strip decodes as zeros.
Co-authored-by: Khawar Zamman Wani <28915582+wanikhawar@users.noreply.github.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Port behavior and regression coverage into current modules; preserve current upstream behavior.
Integrate lossless export-only JXL and independent decoder regression matrix, preserving current JPEG/TIFF save extensions and 12-bit/arithmetic JPEG decoder dependency.
The new plausibility check refused valid files that main opens:
- EXR DWAA/DWAB parts: they go past 2^16:1 (a flat 8192x4320 DWAB frame
with 12 alpha-scheme mattes is 29,948 bytes, 75,629:1; run-length
channels zlib-packed on top reach about 132,000:1). DWA parts now get
2^18:1, so a header of a few hundred bytes can ask for about 100 MB,
not gigabytes.
- BMP RLE8/RLE4: end-of-line, end-of-bitmap and delta escapes leave pixels
unwritten, so no ratio bounds a valid file (an EOB-only 4000x4000 RLE8 is
1,080 bytes). Skipped like HDR.
- GIF: the decoder's size is the logical screen, which needs no data (a
4000x4000 screen with a 1x1 frame is 35 bytes). The first frame must be
backed, and the screen only past 256 MiB, so a 35-byte GIF still can't
declare a 30000x30000 screen.
The declared-size products saturate: a Radiance header of 2^31 x 2^31
read without limits overflowed u64 in debug builds.
Co-authored-by: FinnNk <1424666+FinnNk@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
zlib-rs maps level 1 to deflate_quick, which writes static Huffman blocks
only, so every Compression::fast() caller (PDF export and print, brush
presets, PNG Fast) wrote 5-32% bigger streams than miniz's level 1 did.
Level 3 is the lowest level that is never bigger than miniz level 1 on the
docs/images photos, raw and Up-filtered (level 2 was +0.9% on one); it is
0-16% smaller and takes 1.2-3.4 times as long. The new test exports a
noisy-gradient RGB document as PDF and checks the image stream against
miniz level 1 of the same samples: 603007 vs 547315 bytes with fast(),
passes with level 3. The PR's doc no longer says the levels are unchanged.
Main moved the io integration tests into crates/io/tests/it/, so the new
deflate_compatibility.rs moves there and uses crate::common.
Co-authored-by: Rene Leonhardt <65483435+reneleonhardt@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* chore: resolve SonarCloud maintainability issues
* fix: format imports and fix effect_entry argument type
* fix: restore test closure and wildcard import that broke compilation
* refactor: split composite_to_surface to reduce cognitive complexity
A decode kept the first MAX_EXTRA_SAMPLES (16) extra samples and dropped
the rest without a warning, though files with up to 64 samples a pixel
open: an RGB TIFF with 20 extra samples opened with 16 and no notice, and
a save then lost the other 4. The decode now adds ExtraSamplesOverLimit
with the count left out.
Found by CodeRabbit on #9386.
Co-authored-by: Jason DeBoever <615848+jasallen@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The extra-sample budget check failed the whole decode, so a TIFF whose
image fits max_alloc but whose extra channels don't (e.g. 12000x8000
RGB8 with 5 alpha channels under wasm's 1 GiB limit) no longer opened,
though main opens it. Now the image decodes without the extra samples
and carries a DecodeWarning::ExtraSamplesDropped.
ExtraSamples values above 2 are reserved (TIFF 6.0, p. 31); they now
read as 0 (unspecified), so a save never writes a reserved value back.
Co-authored-by: jasallen <615848+jasallen@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The stack carried #7415's codecs code from before its last review. This
brings in 1c5343d5, approved by FredApps: every TIFF extra plane must be
exactly width x height x sample bytes, with its two regression tests.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
psd_import.rs keeps main's warning for an incomplete merged extra channel
next to the PR's Photoshop spot polarity. people.toml takes main's entries
plus jasallen's own. Docs take both sides' status lines and revision rows;
docs/scorecard.md is regenerated.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Photoshop reads and writes IFF; PhotoCraft opened Amiga IFF/ILBM files
but had no writer. Saving as .iff now writes a FORM ILBM with a BMHD and
a ByteRun1-compressed BODY of 24 bitplanes (red, green and blue, least
significant bit first), one plane row at a time. An image with alpha
also gets a mask plane (masking 1): alpha of 128 or more is opaque, and
when any pixel is not fully opaque the export warns that alpha is
reduced to on/off transparency. Gray is expanded to RGB, 16-bit and
float images are reduced to 8 bits, and images wider or taller than
65535 pixels are refused.
The reader also opens 24-bitplane ILBMs, so a saved file reopens with
the same pixels. A transparent color index in a 24-bitplane file is
refused as unsupported.
IFF leaves ASYMMETRIC_EXCEPTIONS, Save As offers Amiga IFF, and the
codecs README, file-format parity and G10 are updated.
Limits::banded() lifted the 2^30 px cap, and the only failure left was a
tile allocation error. Linux overcommits, so the tiles were allocated
anyway and writing them had the out-of-memory killer end the app and
every open document. A valid all-black Deflate RGB8 TIFF declaring
100,000 x 100,000 px (30 GB of pixels) is a few tens of MB on disk and
passes the bomb guard.
- io memory.rs: memory::available() is MemAvailable plus free swap on
Linux, capped by the process's cgroup v2 memory.max (and those above
it) and memory.swap.max; None elsewhere (Windows refuses past its
commit limit, which the importer already reports). check_fits refuses
when the need plus a 512 MiB reserve doesn't fit.
- tiff_to_document checks the layer's tiles plus the working copies of
one group before allocating anything: "not enough memory to open this
100000 x 100000 px image: it needs about 41.7 GB and 7.8 GB is free".
- codecs TiffBands::group_bytes gives the largest group; a single-strip
file is one group (the whole image), which the check now counts.
- TiffBands puts Truncated before MorePages, as the whole-image decode.
- raster: try_write_interleaved reports a wrong length as
AllocationError::LengthMismatch { expected, got }, not SizeOverflow.
- Comments and docs no longer name another editor.
Tests: io a_tiff_larger_than_free_memory_is_refused (300,000^2 black
RGB8, Linux) was OOM-killed in a 4 GB cgroup without the check; memory
unit tests for the policy and the meminfo/cgroup parsing; codecs
a_cut_off_page_of_several_warns_in_the_whole_image_order and
group_bytes_is_the_largest_group_handed_over.
The first comment claimed the check covers the Lab re-type, which happens later
in `write_image`. Say instead what the check actually adds: the planes are
validated in the sample type they now hold after `converted`, and a plane that
`Image::convert` took but could not rebuild arrives here empty rather than
retyped.
The public `ExtraSample` contract is one sample per pixel in the image's sample
type, but the writer only stopped when a requested slice was missing, so an
oversized plane was silently truncated and a short one was dropped mid-image.
`Image::convert` leaves a plane whose length does not match the image alone
(deliberately: importers drop such a plane with a warning), so encoding a Lab
F32 image also reinterpreted a wrong-sized plane at the file's 16 bits.
Check every plane against width * height * sample.bytes(), with checked
arithmetic, at both ends: on the source image before `converted` brings it to
the plan's sample type, and on the image actually written, which the Lab path
re-types. A plane of any other length is a CodecError::Encode naming the plane,
its length and the length the image needs.
Regressions: too long, too short and empty planes at 8 bits, and a 7- or 9-byte
plane for a two-pixel Lab F32 image, which the U16 conversion used to accept.
Arithmetic-coded frames (SOF9-SOF11) and SOF0/SOF1 frames whose precision
isn't 8 bits go to craft-jpeg; everything else stays on zune-jpeg. 12-bit
files open as 16-bit images. PhotoCraft's own marker scan still supplies
ICC, EXIF, XMP, resolution and Photoshop resources, and damaged or truncated
entropy data adds the Truncated warning. A truncated scan too short for the
frame it declares is refused, as on the zune-jpeg path.
craft-jpeg is pinned to the aiongg fork until it lands on storytold.
Co-authored-by: Michael Higgins <mhiggins217@gmail.com>
The guard now bounds the decoded size by the bytes the strip tables hold
(x4096), not by the file length. CCITT Group 4 codes a blank row in one
bit, so a blank 600 dpi A4 landscape (7016x4961) or A3 page compresses
past 4096:1. With ordinary metadata or other pages in the file, main
opened such a page; this PR refused it ("the strips or tiles in this
file hold 624 bytes, too few for a 7016x4961 image"). Group 4
(compression 4, the only CCITT variant the decoder reads) now gets a
ratio of the strip or tile width, capped at the decoder's 65535.
Test: a_blank_fax_page_wider_than_4096_px_passes_the_bomb_guard fails
without the change, and checks a Group 4 strip too short for its page
is still refused.
Co-authored-by: wanikhawar <28915582+wanikhawar@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Junk where a marker should be (which the decoder skips) bought frame
budget as padding did. The walk still resynchronises at the next marker
but no longer counts the skipped bytes, and junk with no marker after
it ends the image data there.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A stray byte before APP padding stopped the walk, and the unread tail,
padding included, counted as scan data. The walk now resynchronises at
the next marker, as a lenient decoder does, so padding after stray
bytes is still recognised; the skipped bytes count as scan data.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A complete file's frame-size budget was its length, which APP padding
inflates. Every file is now measured on the scan data the walk crossed;
where the walk stops early on an odd layout, what it couldn't read
counts as scan data, so such files still decode while padding before
that point never counts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main gained a guard refusing truncated scans too short for their
frame; this branch's own header check refused those files first with a
different error. Drop it and widen main's guard instead: a complete
file is measured on its length (the scan walk may stop early on odd
marker layouts), a truncated one on its scan data as before. A frame
needs at least a one-bit DC code per 8x8 block, so 2048 pixels a byte
stays far above any real JPEG.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(codecs): WebP export keeps the resolution
Every WebP export with metadata warned that the resolution would be
dropped, and lost it unless the image already had EXIF with resolution
tags. WebP has no resolution field of its own, so both encoders now
write the resolution into EXIF, adding the tags (or a minimal EXIF
block when the image has none), and WebP reports DPI support.
Part of #2399
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(codecs): report WebP DPI as dropped when the source EXIF can't be parsed
That block is written as it is rather than replaced, so its metadata
survives and the lost resolution is reported. The metadata test also
checks the rest of the EXIF (Orientation) survives.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(codecs): spell the WebP metadata test's bytes as escapes
The Orientation tag and the unparseable EXIF block were raw control
bytes, which also made git keep the file's CRLF line endings; both are
escapes now and the file is LF again.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(codecs): no EXIF block just for WebP's default 72 ppi
A WebP without EXIF already opens at 72 ppi, so writing a minimal EXIF
for the default only turned every web export into the extended (VP8X)
container (and broke the automation test expecting a simple lossy file).
EXIF the image already has still gets its resolution tags updated.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(codecs): WebP DPI kept by XMP isn't reported as dropped
When the image's EXIF can't be parsed but its XMP records the
resolution, the WebP keeps it through XMP, which readers check first.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
DPX at 10 or 12 bits and Cineon (always 10) are planned as U16, so
DepthReduced, which compares sample types, never fired: a 16-bit document
saved at 10 bits lost six bits per sample silently, and a float one was
told it would be reduced to 16-bit.
FidelityWarning::BitsReduced { from, bits } names the stored depth. Float
samples always get it; U16 ones only when some sample is not already a
code value at that depth, so a frame opened from a 10- or 12-bit file
saves back without a warning. U8 widens losslessly and stays silent.