Compare commits

...
Author SHA1 Message Date
Claude 4b7bd938e6 Merge #1024 (carry) 2026-10-08 20:19:13 +00:00
mnmlywandClaude Opus 5.5 94c8dfaed5 Content-Aware fill returns the size error for a huge selection extent instead of panicking (#963)
The automatic sampling margin was three quarters of the selection's larger side, computed
as `ext * 3` in u32 (edit.fill) or i32 (edit.contentAwareFill) before the window cap, so a
loaded document with a far-flung selection overflowed and panicked. edit.contentAwareFill
also had no window cap at all and could try to allocate a canvas-sized window.

The margin is now one shared function computed in u64 and saturated to i32, and both
commands check the window against the same 400 M-pixel cap before reading or allocating.
Content-Aware Move uses the shared margin too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 20:19:06 +00:00
Claude 1e7e02081e Merge #1090 2026-10-08 20:18:43 +00:00
Claude 9e31587fec Merge #1172 2026-10-08 20:18:43 +00:00
Claude bc8434bc69 Merge #1176 2026-10-08 20:18:42 +00:00
Claude 57b00a80d4 Merge #1179 2026-10-08 20:18:41 +00:00
Claude 1247e37167 Merge #1183 2026-10-08 20:18:40 +00:00
Brandon ThomasandClaude Opus 5.5 ea74cede41 lens_cmds tests: move the wasm32 cfg back onto lens_correction_batch_over_files
The new Camera Raw test was inserted between the cfg and the batch-over-files
test, which gated the wrong test.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 14:26:14 -04:00
wstockandClaude Fable 5.1 b5213c3280 filter.cameraRaw: refuse an area too large to develop instead of overflowing
Fixes #962. `rgba_region` sized its buffer with `area.width() * area.height()`
in u32, so a legal 65536×65536 canvas (2^32 pixels) overflowed the product and
the command's `run` panicked; dispatch turned that into an internal error. In
release the product wraps to 0 and the first tile read would index past the
empty buffer instead.

The command now checks the area it is about to develop (canvas ∪ the layer's
content) against `MAX_RAW_PIXELS` (400 MP, ≥ 48 bytes per pixel of working
memory) and returns bad-params with the size and the limit. `camera_raw_surface`
carries the same guard so the preview and Smart Filter paths can never overflow
either; the buffer length is computed in u64.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-10-08 14:24:57 -04:00
wstockandClaude Fable 5.1 dbffa7b901 channel.merge: reject a repeated source index instead of closing an unrelated document
Fixes #934. `channel.merge` closes its sources by current index, sorted and in
reverse, so `documents: [0, 0, 1]` passed validation and then ran `close(1)`,
`close(0)`, `close(0)`; the last call removed whatever document had shifted
into index 0, i.e. one that was never named, with its unsaved edits and
history. Repeated indices are now rejected as bad params before anything is
built or closed, and the first-source lookup no longer indexes the document
list directly.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-10-08 11:22:52 +00:00
wstockandClaude Fable 5.1 1ece514285 fix(io): a PSD note icon near i32::MAX saturates on save instead of overflowing (#939)
`write_note` built the icon rectangle as `[y, x, y + 20, x + 16]` with plain
i32 arithmetic on a position that comes straight from the file's `Anno`
block (or from `notes.set`). A stored icon top at or above `i32::MAX - 19`
overflowed on every PSD save, because `export_blocks` regenerates the
comparison block before deciding whether the stored one can be kept
verbatim: a panic in debug (surfaced by `file.saveACopy` as an internal
error, no file written), a wrapped bottom edge in release.

The far edge now uses `saturating_add`, matching the cast that already
saturates the origin, so the written rectangle is always representable and
the note survives a round trip at its stored position. No signature change:
`write_note` stays infallible and unchanged notes still compare byte-exact.

Test: `a_note_icon_near_i32_max_saves_with_representable_geometry` builds
the issue's `Anno` fixture at `i32::MAX - 5` (and a far-left and
`i32::MIN` variant), imports, exports, checks the written icon rectangle
and re-imports; also covers a model position beyond i32 and NaN, and the
ordinary `[42, 28]` control. Panicked at `annotations_map.rs:183` on main.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-10-08 11:16:59 +00:00
wstockandClaude Fable 5.1 f25bf12b0e fix(format): refuse to save a document holding a NaN or infinite float (#1101)
serde_json writes a non-finite float as `null`, and the manifest's plain
`f32`/`f64` fields refuse `null` on load. So a document that picked one up
(a PSD `expA` block, a descriptor double) saved and autosaved with a success
report, and every later open failed with `manifest JSON: invalid type:
null, expected f32`. JSON has no spelling for these values, so the save is
refused instead of writing a file that can never be read back.

`finite::check` walks the manifest with a serde `Serializer` that produces
nothing and fails on the first non-finite float, tracking struct fields,
sequence indices, variants and map keys so the error names the value
(`document.layers[2].content.adjustment.exposure`). `PcraftWriter::prepare`
runs it before serialising, so one-shot, incremental, directory and
autosave saves all share it. New `FormatError::NonFinite { path }` with an
actionable message.

Tests: unit (`finite_values_pass_and_each_position_is_named`: each
container kind reports its path) and integration
(`a_non_finite_float_is_refused_at_save_rather_than_breaking_every_later_load`:
NaN/-inf in an Exposure layer and +inf in `resolution_dpi` are refused with
the field named, through both `save_to_bytes` and `PcraftWriter`, and the
finite document round-trips as before). The integration case was the
issue's repro: on main it saved and failed to load.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-10-08 11:06:54 +00:00
wstockandClaude Fable 5.1 891be5f473 ORF: a file cut short inside IFD0 is malformed, not the compressed variant (#1008)
The "is this the undecoded compressed/packed ORF" guard summed the strip
byte counts and compared the sum with width × height × 2. Tiff::tag_uints
returns an empty list when the entry is missing or unreadable, so a file
whose IFD0 entry table is truncated (a partial download, a damaged card)
summed to zero and was reported as "Olympus compressed (or packed) ORF is
not decoded" — a format the user's camera does not produce — while the same
file cut a few bytes earlier or later was correctly reported as malformed.

Missing or unreadable strip byte counts are now RawError::Malformed before
the compression check. Regression test: a synthetic uncompressed ORF cut
right before its StripByteCounts entry is Malformed, and no prefix of the
file reads as the compressed variant.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-10-08 07:45:33 +00:00
15 changed files with 745 additions and 19 deletions

No files matched your search

+9 -1
View File
@@ -1315,7 +1315,15 @@ fn merge(s: &mut Session, p: &Value) -> Result<Value> {
if srcs.len() < mode.color_channels() {
return Err(bad(cmd, format!("{mode:?} needs {} grayscale documents, got {}", mode.color_channels(), srcs.len())));
}
let first = s.documents()[srcs[0]].doc.clone();
// Each source closes by its *current* index, so a repeated index would shift the later
// removals onto a document that was never named (#934). Refuse it rather than guess.
let mut seen = std::collections::HashSet::new();
if let Some(dup) = srcs.iter().find(|i| !seen.insert(**i)) {
return Err(bad(cmd, format!("document {dup} is listed more than once")));
}
let Some(first) = srcs.first().and_then(|&i| s.documents().get(i)).map(|d| d.doc.clone()) else {
return Err(bad(cmd, "bad document index"));
};
let mut planes = Vec::new();
for &i in &srcs {
let d = &s.documents()[i].doc;
+23
View File
@@ -450,6 +450,29 @@ fn split_and_merge_channels() {
assert!(s.execute("channel.merge", json!({"mode": "cmyk"})).is_err());
}
#[test]
fn merge_rejects_a_repeated_source_and_keeps_unrelated_documents() {
// #934: `documents: [0, 0, 1]` passed validation, then the close loop ran `close(1)`,
// `close(0)`, `close(0)` and the last call removed the unrelated RGB document at index 2.
let mut s = session();
s.execute("channel.split", json!({})).unwrap();
assert_eq!(s.documents().len(), 3);
s.execute("file.new", json!({"width": 40, "height": 20})).unwrap();
s.execute("edit.fill", json!({"color": "#00ff00"})).unwrap();
let keep = s.documents()[3].doc.id;
let before: Vec<_> = s.documents().iter().map(|d| d.doc.id).collect();
let err = s.execute("channel.merge", json!({"mode": "rgb", "documents": [0, 0, 1]})).unwrap_err();
assert!(err.to_string().contains("more than once"), "{err}");
let after: Vec<_> = s.documents().iter().map(|d| d.doc.id).collect();
assert_eq!(after, before, "a rejected merge closes nothing");
assert!(s.documents().iter().any(|d| d.doc.id == keep));
assert!(s.set_active(3) && s.undo(), "the unrelated document keeps its history");
// Distinct indices still merge and close exactly the named sources.
s.execute("channel.merge", json!({"mode": "rgb", "documents": [0, 1, 2]})).unwrap();
assert_eq!(s.documents().len(), 2);
assert!(s.documents().iter().any(|d| d.doc.id == keep));
}
#[test]
fn duplicate_to_other_document() {
let mut s = session();
+5 -3
View File
@@ -341,12 +341,13 @@ fn content_aware_fill_as(s: &mut Session, p: &Value, cmd: &'static str, label: &
if hb.is_empty() {
return Err(bad(cmd, "the selection is outside the canvas"));
}
let ext = hb.width().max(hb.height()) as i32;
// The canvas is at most i32 wide, so the extent fits; saturate anyway rather than wrap (#963).
let ext = i32::try_from(hb.width().max(hb.height())).unwrap_or(i32::MAX);
let sampling = str_or(p, "sampling", "auto").to_string();
let custom_mask: Option<Surface> = p.get("channel").and_then(|v| channel_mask(&doc, v)).cloned();
let custom_rect = if p.get("area").is_some() { Some(rect_param(p, "area", cmd)?) } else { None };
let window = match sampling.as_str() {
"auto" => hb.inflate((ext * 3 / 4).max(32)),
"auto" => hb.inflate(crate::fill_cmds::sampling_margin(hb)),
"rectangular" => hb.inflate(int(p, "margin").map_or(ext.max(16), |m| m.clamp(0, 100_000) as i32)),
"custom" => {
let r = match (custom_rect, custom_mask.as_ref()) {
@@ -373,7 +374,8 @@ fn content_aware_fill_as(s: &mut Session, p: &Value, cmd: &'static str, label: &
let surf = doc.layer(id).and_then(|l| l.surface()).ok_or(EngineError::NoLayer(id))?;
let fmt = surf.format();
let n = fmt.channels();
let (w, h) = (window.width() as usize, window.height() as usize);
// Refuse a window too large to read before allocating for it, whatever the sampling (#963).
let (w, h) = crate::fill_cmds::window_size(window)?;
// A background job when started with `Session::start` (#210): reading the window and the
// PatchMatch fill run on a worker against the document snapshot, cancellable per row band.
crate::jobs::run(
+59
View File
@@ -116,6 +116,65 @@ fn content_aware_fill_huge_area_neither_panics_nor_wraps() {
}
}
/// `blob_session` with the canvas declared as `size` and a sparse selection of `rects`, as a
/// loaded `.pcraft` can hold (#963). Nothing here allocates more than a few tiles.
fn huge_selection(size: (u32, u32), rects: &[Rect]) -> Session {
let mut s = blob_session(8);
let st = s.active_mut().unwrap();
let doc = std::sync::Arc::make_mut(&mut st.doc);
doc.size = photocraft_geom::Size::new(size.0, size.1);
let mut sel = Surface::new(PixelFormat::GRAY8);
for r in rects {
sel.fill_rect(*r, &[1.0]);
}
doc.selection = Some(sel);
s
}
/// A sampling window too large to read, or whose margin overflows, is the size error: no panic,
/// no huge allocation, the document and history untouched (#963).
#[test]
fn content_aware_fill_huge_window_is_an_error() {
// Largest extent whose `ext * 3` still fits in i32.
const EDGE: i32 = i32::MAX / 3;
let max = i32::MAX;
let big = (2_000_000_000, 2_000_000_000);
let dot = Rect::new(0, 0, 1, 1);
let cases = [
("auto, wide, just above", big, vec![dot, Rect::new(EDGE, 0, EDGE + 1, 1)], json!({})),
("auto, tall, just above", big, vec![dot, Rect::new(0, EDGE, 1, EDGE + 1)], json!({})),
("auto, wide, just below", big, vec![dot, Rect::new(EDGE - 1, 0, EDGE, 1)], json!({})),
(
"auto, extreme coordinates",
(max as u32, max as u32),
vec![Rect::new(i32::MIN, i32::MIN, i32::MIN + 1, i32::MIN + 1), dot, Rect::new(max - 2, max - 2, max - 1, max - 1)],
json!({}),
),
(
"rectangular, small selection",
(300_000, 300_000),
vec![Rect::new(150_000, 150_000, 150_001, 150_001)],
json!({"sampling": "rectangular", "margin": 100_000}),
),
("rectangular, huge extent", big, vec![dot, Rect::new(EDGE + 1, 0, EDGE + 2, 1)], json!({"sampling": "rectangular"})),
("custom, whole canvas", big, vec![dot], json!({"sampling": "custom", "area": [0, 0, 2_000_000_000, 2_000_000_000]})),
];
for (name, size, rects, p) in cases {
let mut s = huge_selection(size, &rects);
let before = s.active().unwrap().doc.clone();
let past = s.active().unwrap().history.past_len();
let err = s.execute("edit.contentAwareFill", p).expect_err(name).to_string();
assert!(err.contains("too large for Content-Aware fill"), "{name}: {err}");
let st = s.active().unwrap();
assert!(std::sync::Arc::ptr_eq(&before, &st.doc), "{name}: the document changed");
assert_eq!(st.history.past_len(), past, "{name}: a history step was recorded");
}
// Control: an ordinary selection still fills.
let mut s = huge_selection((64, 48), &[Rect::new(26, 16, 38, 30)]);
let r = s.execute("edit.contentAwareFill", json!({"colorAdaptation": "none"})).unwrap();
assert!(r["filled"].as_u64().unwrap() > 100);
}
#[test]
fn content_aware_fill_removes_object_at_all_depths() {
for depth in [8, 16, 32] {
+84 -7
View File
@@ -176,15 +176,10 @@ fn content_aware(surf: &Surface, sel: &Surface, canvas: Rect, color_adaptation:
if hole_bounds.is_empty() {
return Err(EngineError::Other("the selection is outside the canvas".into()));
}
let ext = hole_bounds.width().max(hole_bounds.height());
let window = hole_bounds.inflate(i32::try_from(ext * 3 / 4).unwrap_or(i32::MAX).max(32)).intersect(&canvas);
let window = hole_bounds.inflate(sampling_margin(hole_bounds)).intersect(&canvas);
let fmt = surf.format();
let n = fmt.channels();
let (w, h) = (window.width() as usize, window.height() as usize);
// Same cap as other whole-image operations: refuse absurd windows instead of allocating.
if w.saturating_mul(h) > 400_000_000 {
return Err(EngineError::Other("the selection is too large for Content-Aware fill".into()));
}
let (w, h) = window_size(window)?;
let img = surf.read_region(window);
let mut hole = vec![false; w * h];
for (i, hv) in hole.iter_mut().enumerate() {
@@ -209,6 +204,25 @@ fn content_aware(surf: &Surface, sel: &Surface, canvas: Rect, color_adaptation:
Ok(Source::Pixels(window, px))
}
/// The automatic Content-Aware sampling margin around `hole`: three quarters of its larger side,
/// at least 32 pixels. Worked out in u64 because a loaded document can declare a selection whose
/// extent times three overflows u32 (#963); a margin beyond i32 saturates, as `Rect::inflate` does.
pub(crate) fn sampling_margin(hole: Rect) -> i32 {
let ext = u64::from(hole.width().max(hole.height()));
i32::try_from(ext * 3 / 4).unwrap_or(i32::MAX).max(32)
}
/// The size of a Content-Aware sampling window, or an error for a window too large to read and
/// fill (#963). Same cap as other whole-image operations: refuse absurd windows instead of
/// allocating.
pub(crate) fn window_size(window: Rect) -> Result<(usize, usize)> {
let (w, h) = (window.width(), window.height());
if u64::from(w) * u64::from(h) > 400_000_000 {
return Err(EngineError::Other("the selection is too large for Content-Aware fill".into()));
}
Ok((w as usize, h as usize))
}
/// How the source goes onto the target.
#[derive(Clone, Copy)]
struct Blend {
@@ -499,4 +513,67 @@ mod tests {
// Out-of-range opacity is clamped.
s.execute(CMD, json!({"contents": "white", "opacity": 1e300})).unwrap();
}
/// A session whose canvas is `size` and whose selection is the given (sparse) rectangles, as
/// a loaded `.pcraft` can declare (#963): the canvas size is not capped on load and the
/// selection tiles can lie far apart. Nothing here allocates more than a few tiles.
fn huge_selection(size: (u32, u32), rects: &[Rect]) -> Session {
let mut s = session();
s.execute(CMD, json!({"contents": "color", "color": "#336699"})).unwrap();
let st = s.active_mut().unwrap();
let doc = std::sync::Arc::make_mut(&mut st.doc);
doc.size = photocraft_geom::Size::new(size.0, size.1);
let mut sel = Surface::new(photocraft_color::PixelFormat::GRAY8);
for r in rects {
sel.fill_rect(*r, &[1.0]);
}
doc.selection = Some(sel);
s
}
/// Content-Aware fill on a selection whose extent overflows the sampling-window arithmetic
/// (#963) returns the size error and leaves the document and history untouched.
#[test]
fn content_aware_huge_extent_is_an_error() {
// Largest extent whose `ext * 3` still fits in u32.
const EDGE: i32 = (u32::MAX / 3) as i32;
let max = i32::MAX;
let cases: [(&str, (u32, u32), Vec<Rect>); 7] = [
// The issue's repro: a second selection tile 5 600 000 tiles to the right.
("issue repro", (2_000_000_000, 2_000_000_000), vec![Rect::new(0, 0, 8, 8), Rect::new(5_600_000 * TILE_SIZE, 0, 5_600_000 * TILE_SIZE + 8, 8)]),
("wide, just above", (2_000_000_000, 2_000_000_000), vec![Rect::new(0, 0, 1, 1), Rect::new(EDGE, 0, EDGE + 1, 1)]),
("tall, just above", (2_000_000_000, 2_000_000_000), vec![Rect::new(0, 0, 1, 1), Rect::new(0, EDGE, 1, EDGE + 1)]),
("wide, just below", (2_000_000_000, 2_000_000_000), vec![Rect::new(0, 0, 1, 1), Rect::new(EDGE - 1, 0, EDGE, 1)]),
("tall, just below", (2_000_000_000, 2_000_000_000), vec![Rect::new(0, 0, 1, 1), Rect::new(0, EDGE - 1, 1, EDGE)]),
// The largest canvas a Rect can hold, selected corner to corner, plus a piece off the
// canvas at the most negative coordinate.
(
"extreme coordinates",
(max as u32, max as u32),
vec![Rect::new(i32::MIN, i32::MIN, i32::MIN + 1, i32::MIN + 1), Rect::new(0, 0, 1, 1), Rect::new(max - 2, max - 2, max - 1, max - 1)],
),
// A canvas wider than a Rect can hold is treated as empty, never as a panic.
("canvas beyond i32", (u32::MAX, u32::MAX), vec![Rect::new(0, 0, 1, 1), Rect::new(max - 2, 0, max - 1, 1)]),
];
for (name, size, rects) in cases {
let mut s = huge_selection(size, &rects);
let before = s.active().unwrap().doc.clone();
let past = s.active().unwrap().history.past_len();
let r = s.execute(CMD, json!({"contents": "contentAware", "colorAdaptation": false}));
let err = r.expect_err(name).to_string();
assert!(!err.contains("internal error"), "{name}: {err}");
if name != "canvas beyond i32" {
assert!(err.contains("too large for Content-Aware fill"), "{name}: {err}");
}
let st = s.active().unwrap();
assert!(std::sync::Arc::ptr_eq(&before, &st.doc), "{name}: the document changed");
assert_eq!(st.history.past_len(), past, "{name}: a history step was recorded");
assert!(close(px(&s, 5, 5), [0x33 as f32 / 255.0, 0x66 as f32 / 255.0, 0x99 as f32 / 255.0, 1.0]), "{name}");
}
// Control: an ordinary selection still fills from its surroundings.
let mut s = huge_selection((40, 30), &[Rect::new(15, 10, 21, 16)]);
s.execute(CMD, json!({"contents": "white"})).unwrap();
s.execute(CMD, json!({"contents": "contentAware", "colorAdaptation": false})).unwrap();
assert!(close(px(&s, 17, 12), [0x33 as f32 / 255.0, 0x66 as f32 / 255.0, 0x99 as f32 / 255.0, 1.0]), "{:?}", px(&s, 17, 12));
}
}
+53 -4
View File
@@ -12,7 +12,7 @@ use photocraft_algo::lens::{self, EdgeMode, LensCorrection};
use photocraft_algo::transform::Interp;
use photocraft_algo::wideangle::{self, WideAngle};
use photocraft_color::{ColorMode, SampleType};
use photocraft_doc::{Document, LayerContent, LayerId, SmartFilter};
use photocraft_doc::{Document, Layer, LayerContent, LayerId, SmartFilter};
use photocraft_geom::Rect;
use photocraft_raster::{Surface, from_rgba_into};
use serde_json::{Value, json};
@@ -152,21 +152,36 @@ pub fn raw_params(cmd: &str, p: &Value) -> Result<CameraRaw> {
// ---------- pixels ----------
fn rgba_region(surf: &Surface, area: Rect) -> Vec<[f32; 4]> {
let mut v = vec![[0.0f32; 4]; (area.width() * area.height()) as usize];
/// Largest area Camera Raw develops in one pass. The pipeline holds the whole region as RGBA
/// f32 plus the source and result planes (≥ 48 bytes per pixel), so beyond this a document
/// is refused with an error rather than attempted (#962).
pub const MAX_RAW_PIXELS: u64 = 400_000_000;
/// The number of pixels in `area` if Camera Raw can develop it in one pass.
fn raw_pixels(area: Rect) -> Option<usize> {
let n = u64::from(area.width()) * u64::from(area.height());
(n <= MAX_RAW_PIXELS).then(|| usize::try_from(n).ok()).flatten()
}
fn rgba_region(surf: &Surface, area: Rect, len: usize) -> Vec<[f32; 4]> {
let mut v = vec![[0.0f32; 4]; len];
surf.read_rgba_into(area, &mut v);
v
}
/// Camera Raw on a surface over `area` (RGB / Gray via RGBA, at the surface's depth).
///
/// An area above [`MAX_RAW_PIXELS`] is returned unchanged: the command rejects it with an
/// error first, and this keeps the preview and Smart Filter paths from overflowing.
pub fn camera_raw_surface(surf: &Surface, area: Rect, p: &CameraRaw) -> Surface {
let area = area.intersect(&surf.content_bounds().union(&area));
if area.is_empty() || p.is_identity() {
return surf.clone();
}
let Some(len) = raw_pixels(area) else { return surf.clone() };
let fmt = surf.format();
let (w, h) = (area.width() as usize, area.height() as usize);
let mut px = rgba_region(surf, area);
let mut px = rgba_region(surf, area, len);
camera_raw::develop(&mut px, w, h, p, fmt.sample == SampleType::F32);
let n = fmt.channels();
let orig = surf.read_region(area);
@@ -357,6 +372,19 @@ fn camera_raw_cmd(s: &mut Session, p: &Value) -> Result<Value> {
let cr = raw_params(RAW, p)?;
// New settings are strict; stored Smart Filters re-apply through the lenient `raw_params`.
cr.validate().map_err(|e| bad(RAW, e))?;
// The pixel path works on the canvas plus the layer's content in one buffer; a legal
// 65536×65536 canvas is 2^32 pixels, which overflowed the u32 size arithmetic (#962).
if !cr.is_identity() {
let d = s.active().ok_or(EngineError::NoDocument)?;
let id = target(s, p)?;
let area = d.doc.layer(id).and_then(Layer::surface).map_or(d.doc.bounds(), |sf| d.doc.bounds().union(&sf.content_bounds()));
if raw_pixels(area).is_none() {
return Err(bad(
RAW,
format!("the image is too large for Camera Raw ({}×{} px; max {} megapixels)", area.width(), area.height(), MAX_RAW_PIXELS / 1_000_000),
));
}
}
let t0 = Stopwatch::start();
let id = run_filter(s, RAW, "Camera Raw Filter", p.clone(), false, &|surf, canvas| camera_raw_surface(surf, canvas.union(&surf.content_bounds()), &cr))?;
Ok(json!({"layer": id.0, "identity": cr.is_identity(), "ms": t0.ms()}))
@@ -554,6 +582,27 @@ mod tests {
assert!((p[0] - p[2]).abs() < 0.01, "{p:?}");
}
#[test]
fn camera_raw_rejects_an_area_too_large_to_develop() {
// #962: `area.width() * area.height()` overflowed u32 on a legal 65536×65536 canvas,
// so `run` panicked and dispatch reported an internal error.
let mut s = Session::new();
s.execute("file.new", json!({"width": 8, "height": 8, "background": "transparent"})).unwrap();
s.execute("image.canvasSize", json!({"width": 65536, "height": 65536, "extensionColor": "transparent"})).unwrap();
let err = s.execute(RAW, json!({"exposure": 1})).unwrap_err();
assert!(matches!(err, EngineError::BadParams { .. }), "{err}");
assert!(err.to_string().contains("too large"), "{err}");
// The surface function itself never overflows either: an oversized area is left alone.
let d = s.active().unwrap();
let surf = d.doc.layer(d.active_layer.unwrap()).unwrap().surface().unwrap();
let cr = raw_params(RAW, &json!({"exposure": 1})).unwrap();
let out = camera_raw_surface(surf, Rect::from_xywh(0, 0, 65536, 65536), &cr);
assert_eq!(out.content_bounds(), surf.content_bounds());
// A normal document still develops.
let mut s = session(8);
s.execute(RAW, json!({"exposure": 1})).unwrap();
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn lens_correction_batch_over_files() {
@@ -150,8 +150,7 @@ fn erode(w: usize, h: usize, m: &[bool], r: usize) -> Vec<bool> {
/// Where a fill samples from around `r`: as Edit › Content-Aware Fill's automatic sampling area,
/// three quarters of the extent around it (at least 32, at most [`MAX_MARGIN`] pixels).
fn window(r: Rect, canvas: Rect) -> Rect {
let ext = r.width().max(r.height()) as i32;
r.inflate((ext * 3 / 4).clamp(32, MAX_MARGIN)).intersect(&canvas)
r.inflate(crate::fill_cmds::sampling_margin(r).min(MAX_MARGIN)).intersect(&canvas)
}
/// The pixels a step works on: the targeted surface, or with Sample All Layers the visible
+335
View File
@@ -0,0 +1,335 @@
//! Save-time check that the manifest holds only finite floats (issue #1101).
//!
//! JSON cannot spell NaN or the infinities: `serde_json` writes them as `null`, which the
//! manifest's plain `f32`/`f64` fields refuse on load. Such a document would save (and autosave)
//! with a success report and then never open again. So the save is refused instead, naming the
//! value, by walking the manifest with a serializer that produces nothing and fails on the first
//! non-finite float.
use std::fmt;
use serde::ser::{self, Serialize};
/// Path of the first non-finite float, e.g. `document.layers[2].content.adjustment.exposure`
/// (`Other` is reserved for a `Serialize` impl failing on its own, which the derived manifest
/// never does).
#[derive(Debug)]
pub enum Problem {
NonFinite { path: String },
Other(String),
}
impl fmt::Display for Problem {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Problem::NonFinite { path } => write!(f, "`{path}` is NaN or infinite"),
Problem::Other(m) => f.write_str(m),
}
}
}
impl std::error::Error for Problem {}
impl ser::Error for Problem {
fn custom<T: fmt::Display>(msg: T) -> Self {
Problem::Other(msg.to_string())
}
}
/// `Ok(())` when every float in `value`'s serialized form is finite; otherwise the path of the
/// first one that is not.
pub fn check<T: Serialize + ?Sized>(value: &T) -> Result<(), Problem> {
value.serialize(Check { path: String::new() })
}
struct Check {
path: String,
}
impl Check {
fn at(&self, seg: fmt::Arguments<'_>) -> Check {
Check { path: format!("{}{}", self.path, seg) }
}
fn float(self, finite: bool) -> Result<(), Problem> {
if finite { Ok(()) } else { Err(Problem::NonFinite { path: self.path.trim_start_matches('.').to_owned() }) }
}
}
type R = Result<(), Problem>;
impl ser::Serializer for Check {
type Ok = ();
type Error = Problem;
type SerializeSeq = Seq;
type SerializeTuple = Seq;
type SerializeTupleStruct = Seq;
type SerializeTupleVariant = Seq;
type SerializeMap = Map;
type SerializeStruct = Struct;
type SerializeStructVariant = Struct;
fn serialize_bool(self, _: bool) -> R {
Ok(())
}
fn serialize_i8(self, _: i8) -> R {
Ok(())
}
fn serialize_i16(self, _: i16) -> R {
Ok(())
}
fn serialize_i32(self, _: i32) -> R {
Ok(())
}
fn serialize_i64(self, _: i64) -> R {
Ok(())
}
fn serialize_i128(self, _: i128) -> R {
Ok(())
}
fn serialize_u8(self, _: u8) -> R {
Ok(())
}
fn serialize_u16(self, _: u16) -> R {
Ok(())
}
fn serialize_u32(self, _: u32) -> R {
Ok(())
}
fn serialize_u64(self, _: u64) -> R {
Ok(())
}
fn serialize_u128(self, _: u128) -> R {
Ok(())
}
fn serialize_f32(self, v: f32) -> R {
self.float(v.is_finite())
}
fn serialize_f64(self, v: f64) -> R {
self.float(v.is_finite())
}
fn serialize_char(self, _: char) -> R {
Ok(())
}
fn serialize_str(self, _: &str) -> R {
Ok(())
}
fn serialize_bytes(self, _: &[u8]) -> R {
Ok(())
}
fn serialize_none(self) -> R {
Ok(())
}
fn serialize_some<T: Serialize + ?Sized>(self, v: &T) -> R {
v.serialize(self)
}
fn serialize_unit(self) -> R {
Ok(())
}
fn serialize_unit_struct(self, _: &'static str) -> R {
Ok(())
}
fn serialize_unit_variant(self, _: &'static str, _: u32, _: &'static str) -> R {
Ok(())
}
fn serialize_newtype_struct<T: Serialize + ?Sized>(self, _: &'static str, v: &T) -> R {
v.serialize(self)
}
fn serialize_newtype_variant<T: Serialize + ?Sized>(self, _: &'static str, _: u32, variant: &'static str, v: &T) -> R {
v.serialize(self.at(format_args!(".{variant}")))
}
fn serialize_seq(self, _: Option<usize>) -> Result<Seq, Problem> {
Ok(Seq { path: self.path, i: 0 })
}
fn serialize_tuple(self, _: usize) -> Result<Seq, Problem> {
Ok(Seq { path: self.path, i: 0 })
}
fn serialize_tuple_struct(self, _: &'static str, _: usize) -> Result<Seq, Problem> {
Ok(Seq { path: self.path, i: 0 })
}
fn serialize_tuple_variant(self, _: &'static str, _: u32, variant: &'static str, _: usize) -> Result<Seq, Problem> {
Ok(Seq { path: format!("{}.{variant}", self.path), i: 0 })
}
fn serialize_map(self, _: Option<usize>) -> Result<Map, Problem> {
Ok(Map { path: self.path, key: String::new() })
}
fn serialize_struct(self, _: &'static str, _: usize) -> Result<Struct, Problem> {
Ok(Struct { path: self.path })
}
fn serialize_struct_variant(self, _: &'static str, _: u32, variant: &'static str, _: usize) -> Result<Struct, Problem> {
Ok(Struct { path: format!("{}.{variant}", self.path) })
}
}
struct Seq {
path: String,
i: usize,
}
impl Seq {
fn next<T: Serialize + ?Sized>(&mut self, v: &T) -> R {
let c = Check { path: format!("{}[{}]", self.path, self.i) };
self.i += 1;
v.serialize(c)
}
}
impl ser::SerializeSeq for Seq {
type Ok = ();
type Error = Problem;
fn serialize_element<T: Serialize + ?Sized>(&mut self, v: &T) -> R {
self.next(v)
}
fn end(self) -> R {
Ok(())
}
}
impl ser::SerializeTuple for Seq {
type Ok = ();
type Error = Problem;
fn serialize_element<T: Serialize + ?Sized>(&mut self, v: &T) -> R {
self.next(v)
}
fn end(self) -> R {
Ok(())
}
}
impl ser::SerializeTupleStruct for Seq {
type Ok = ();
type Error = Problem;
fn serialize_field<T: Serialize + ?Sized>(&mut self, v: &T) -> R {
self.next(v)
}
fn end(self) -> R {
Ok(())
}
}
impl ser::SerializeTupleVariant for Seq {
type Ok = ();
type Error = Problem;
fn serialize_field<T: Serialize + ?Sized>(&mut self, v: &T) -> R {
self.next(v)
}
fn end(self) -> R {
Ok(())
}
}
struct Map {
path: String,
key: String,
}
impl ser::SerializeMap for Map {
type Ok = ();
type Error = Problem;
fn serialize_key<T: Serialize + ?Sized>(&mut self, key: &T) -> R {
// Keys are checked like any value; their text (when they have one) names the entry.
key.serialize(Check { path: format!("{}.<key>", self.path) })?;
self.key = serde_json::to_string(key).map_or_else(|_| "?".to_owned(), |k| k.trim_matches('"').to_owned());
Ok(())
}
fn serialize_value<T: Serialize + ?Sized>(&mut self, v: &T) -> R {
v.serialize(Check { path: format!("{}.{}", self.path, self.key) })
}
fn end(self) -> R {
Ok(())
}
}
struct Struct {
path: String,
}
impl Struct {
fn field<T: Serialize + ?Sized>(&mut self, key: &'static str, v: &T) -> R {
v.serialize(Check { path: format!("{}.{key}", self.path) })
}
}
impl ser::SerializeStruct for Struct {
type Ok = ();
type Error = Problem;
fn serialize_field<T: Serialize + ?Sized>(&mut self, key: &'static str, v: &T) -> R {
self.field(key, v)
}
fn end(self) -> R {
Ok(())
}
}
impl ser::SerializeStructVariant for Struct {
type Ok = ();
type Error = Problem;
fn serialize_field<T: Serialize + ?Sized>(&mut self, key: &'static str, v: &T) -> R {
self.field(key, v)
}
fn end(self) -> R {
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde::Serialize;
#[derive(Serialize)]
struct Inner {
gamma: f32,
offsets: Vec<f64>,
}
#[derive(Serialize)]
enum Content {
Exposure { exposure: f32 },
Levels(f64, f64),
}
#[derive(Serialize)]
struct Outer {
dpi: f32,
inner: Option<Inner>,
layers: Vec<Content>,
named: std::collections::BTreeMap<String, f32>,
}
fn outer() -> Outer {
Outer {
dpi: 72.0,
inner: Some(Inner { gamma: 1.0, offsets: vec![0.0, 1.5] }),
layers: vec![Content::Levels(0.0, 1.0), Content::Exposure { exposure: 0.5 }],
named: [("blur".to_owned(), 2.0)].into_iter().collect(),
}
}
fn path(v: &impl Serialize) -> String {
match check(v) {
Err(Problem::NonFinite { path }) => path,
other => panic!("expected a non-finite report, got {other:?}"),
}
}
#[test]
fn finite_values_pass_and_each_position_is_named() {
assert!(check(&outer()).is_ok());
let mut o = outer();
o.dpi = f32::NAN;
assert_eq!(path(&o), "dpi");
let mut o = outer();
if let Some(i) = o.inner.as_mut() {
i.offsets[1] = f64::INFINITY;
}
assert_eq!(path(&o), "inner.offsets[1]");
let mut o = outer();
o.layers[1] = Content::Exposure { exposure: f32::NEG_INFINITY };
assert_eq!(path(&o), "layers[1].Exposure.exposure");
let mut o = outer();
o.layers[0] = Content::Levels(0.0, f64::NAN);
assert_eq!(path(&o), "layers[0].Levels[1]");
let mut o = outer();
o.named.insert("blur".to_owned(), f32::NAN);
assert_eq!(path(&o), "named.blur");
assert_eq!(path(&f32::NAN), "");
}
}
+3
View File
@@ -25,6 +25,7 @@
pub mod atomic;
pub mod autosave;
mod convert;
mod finite;
pub mod manifest;
mod migrate;
pub mod read;
@@ -58,6 +59,8 @@ pub enum FormatError {
TooNew { found: u32, supported: u32 },
#[error("limit exceeded: {0}")]
LimitExceeded(String),
#[error("`{path}` is NaN or infinite, which a .pcraft manifest cannot hold; reset that value and save again")]
NonFinite { path: String },
#[error("I/O: {0}")]
Io(#[from] std::io::Error),
#[error("manifest JSON: {0}")]
+6
View File
@@ -519,6 +519,12 @@ impl PcraftWriter {
thumbnail: opts.thumbnail.as_ref().map(|_| THUMB.to_owned()),
composite: opts.composite.as_ref().map(|_| COMPOSITE.to_owned()),
};
// serde_json writes NaN and the infinities as `null`, which the manifest's float fields
// refuse on load (issue #1101): refuse the save instead, naming the value.
crate::finite::check(&manifest).map_err(|e| match e {
crate::finite::Problem::NonFinite { path } => FormatError::NonFinite { path },
crate::finite::Problem::Other(m) => FormatError::Unsupported(format!("manifest: {m}")),
})?;
let manifest = serde_json::to_vec_pretty(&manifest)?;
check_manifest_depth(&manifest)?;
let stats = SaveStats { tiles_total: c.tiles.len(), blobs_total: c.blobs.len(), manifest_bytes: manifest.len(), ..Default::default() };
+44
View File
@@ -425,3 +425,47 @@ fn video_layer_frames_survive_roundtrip() {
v.frames[2].read_rgba_into(Rect::from_xywh(1, 1, 1, 1), &mut px);
assert!(px[0][0] > 0.9 && px[0][2] < 0.1, "frame 2 is reddish: {:?}", px[0]);
}
/// Issue #1101: serde_json writes NaN and the infinities as `null`, which the manifest's float
/// fields refuse on load, so such a document saved "successfully" and never opened again. The
/// save is refused instead, naming the value; finite documents are unaffected.
#[test]
fn a_non_finite_float_is_refused_at_save_rather_than_breaking_every_later_load() {
use photocraft_doc::Adjustment;
type Mutate = Box<dyn Fn(&mut Document)>;
let base = rich_doc(ColorMode::Rgb, SampleType::U8);
let cases: Vec<(&str, Mutate)> = vec![
(
"exposure",
Box::new(|d| {
d.insert_above(None, Layer::new("adj", LayerContent::Adjustment(Adjustment::Exposure { exposure: f32::NAN, offset: 0.0, gamma: 1.0 })));
}),
),
(
"gamma",
Box::new(|d| {
d.insert_above(
None,
Layer::new("adj", LayerContent::Adjustment(Adjustment::Exposure { exposure: 0.0, offset: 0.0, gamma: f32::NEG_INFINITY })),
);
}),
),
("resolution_dpi", Box::new(|d| d.resolution_dpi = f32::INFINITY)),
];
for (field, mutate) in cases {
let mut doc = base.clone();
mutate(&mut doc);
let err = save_to_bytes(&doc, &SaveOptions::default()).expect_err(field);
assert!(matches!(err, FormatError::NonFinite { .. }), "{field}: {err:?}");
let msg = err.to_string();
assert!(msg.contains(field) && msg.contains("NaN or infinite"), "{field}: {msg}");
// The incremental writer shares the check, for autosave and directory bundles.
let mut w = PcraftWriter::new();
assert!(matches!(w.save_zip(&doc, &SaveOptions::default()), Err(FormatError::NonFinite { .. })), "{field}: PcraftWriter");
}
// The same document with finite values round-trips as before.
let mut doc = base.clone();
doc.insert_above(None, Layer::new("adj", LayerContent::Adjustment(Adjustment::Exposure { exposure: 0.5, offset: 0.0, gamma: 1.0 })));
let back = load_from_bytes(&save_to_bytes(&doc, &SaveOptions::default()).unwrap()).unwrap();
assert_eq!(back, doc);
}
+4 -1
View File
@@ -179,8 +179,11 @@ fn write_note(n: &Note) -> Vec<u8> {
b.push(u8::from(n.open));
b.push(0x1C);
b.extend_from_slice(&1u16.to_be_bytes());
// The position comes from the file or a command and can sit anywhere in i32 (issue #939):
// the cast saturates, and the icon's far edge must saturate too rather than wrap (release) or
// overflow (debug) when the note is re-saved.
let (x, y) = (n.position[0].round() as i32, n.position[1].round() as i32);
for v in [y, x, y + 20, x + 16] {
for v in [y, x, y.saturating_add(20), x.saturating_add(16)] {
b.extend_from_slice(&v.to_be_bytes());
}
let pp = if n.popup == [0.0; 4] { [n.position[0] + 20.0, n.position[1] + 20.0, n.position[0] + 260.0, n.position[1] + 160.0] } else { n.popup };
+84
View File
@@ -63,3 +63,87 @@ fn measurement_scale_roundtrips_through_psd() {
assert_eq!(again.resources.iter().filter(|r| r.id == MEASUREMENT_SCALE).count(), 1);
}
}
/// A PSD `Anno` block with one text note whose icon rectangle is `[top, left, 0, 0]` and whose
/// popup is all zeros (issue #939's fixture).
fn anno_with_icon(top: i32, left: i32) -> Vec<u8> {
let mut entry = Vec::new();
entry.extend_from_slice(b"txtA");
entry.push(1); // open
entry.push(0x1C); // flags
entry.extend_from_slice(&1u16.to_be_bytes()); // optional blocks
for v in [top, left, 0, 0] {
entry.extend_from_slice(&v.to_be_bytes()); // icon: top, left, bottom, right
}
for v in [0i32; 4] {
entry.extend_from_slice(&v.to_be_bytes()); // popup
}
entry.extend_from_slice(&0u16.to_be_bytes()); // colour space: RGB
for _ in 0..4 {
entry.extend_from_slice(&0u16.to_be_bytes());
}
entry.extend_from_slice(&[0u8, 0]); // author: empty Pascal string
entry.extend_from_slice(&[0u8, 0]); // name
entry.extend_from_slice(&[0u8, 0]); // modification date
let text = [0xFEu8, 0xFF, 0x00, b'A']; // UTF-16BE "A"
entry.extend_from_slice(&((text.len() + 12) as u32).to_be_bytes());
entry.extend_from_slice(b"txtC");
entry.extend_from_slice(&(text.len() as u32).to_be_bytes());
entry.extend_from_slice(&text);
let mut out = Vec::new();
out.extend_from_slice(&2u16.to_be_bytes());
out.extend_from_slice(&1u16.to_be_bytes());
out.extend_from_slice(&1u32.to_be_bytes()); // one annotation
out.extend_from_slice(&((entry.len() + 4) as u32).to_be_bytes());
out.extend_from_slice(&entry);
out
}
fn psd_with_anno(anno: Vec<u8>) -> Vec<u8> {
let mut doc = Document::new("notes", Size::new(16, 16), ColorMode::Rgb, SampleType::U8);
doc.layers.push(photocraft_doc::Layer::raster("Background", doc.pixel_format()));
let mut file = PsdFile::from_bytes(&to_psd(&doc)).unwrap();
let mut tb = photocraft_psd::TaggedBlock::new(*b"Anno", anno.clone());
tb.padding = Some(vec![0; (4 - anno.len() % 4) % 4]);
file.global_blocks.push(tb);
file.to_bytes().unwrap()
}
/// Icon rectangle `[top, left, bottom, right]` of the first note in the exported `Anno` block.
fn exported_icon(doc: &Document) -> [i32; 4] {
let f = PsdFile::from_bytes(&to_psd(doc)).unwrap();
let d = &f.global_block(b"Anno").unwrap().data;
// header (2 + 2 + 4), entry length (4), `txtA` (4), open (1), flags (1), optional (2).
let mut out = [0i32; 4];
for (i, v) in out.iter_mut().enumerate() {
let at = 20 + i * 4;
*v = i32::from_be_bytes(d[at..at + 4].try_into().unwrap());
}
out
}
/// Issue #939: a note whose stored icon top sits near `i32::MAX` imported fine and then
/// overflowed `y + 20` on every PSD save (a panic in debug, a wrapped coordinate in release). The
/// far edge saturates instead, so the note survives a round trip with representable geometry.
#[test]
fn a_note_icon_near_i32_max_saves_with_representable_geometry() {
for (top, left) in [(i32::MAX - 5, 0), (0, i32::MAX - 3), (i32::MIN, i32::MIN)] {
let imp = import("notes.psd", &psd_with_anno(anno_with_icon(top, left))).expect("import");
assert_eq!(imp.document.notes[0].position, [f64::from(left), f64::from(top)]);
let icon = exported_icon(&imp.document);
assert_eq!(icon, [top, left, top.saturating_add(20), left.saturating_add(16)], "{top},{left}");
let back = import("notes.psd", &to_psd(&imp.document)).unwrap().document;
assert_eq!((back.notes[0].text.as_str(), back.notes[0].position), ("A", [f64::from(left), f64::from(top)]));
}
// The same through the model: a position a command could set beyond i32 saturates, NaN is 0.
let mut doc = Document::new("notes", Size::new(16, 16), ColorMode::Rgb, SampleType::U8);
doc.notes.push(Note { text: "far".into(), position: [1e300, f64::INFINITY], ..Default::default() });
assert_eq!(exported_icon(&doc), [i32::MAX, i32::MAX, i32::MAX, i32::MAX]);
doc.notes[0].position = [f64::NAN, -1e300];
assert_eq!(exported_icon(&doc), [i32::MIN, 0, i32::MIN + 20, 16]);
// Control: an ordinary note is unchanged.
doc.notes[0].position = [42.0, 28.0];
assert_eq!(exported_icon(&doc), [28, 42, 48, 58]);
let back = import("x.psd", &to_psd(&doc)).unwrap().document;
assert_eq!(back.notes[0].position, [42.0, 28.0]);
}
+8 -1
View File
@@ -72,7 +72,14 @@ pub(crate) fn decode(t: &Tiff, limits: &Limits) -> Result<Sensor> {
let raw = t.ifd_at(t.first_ifd, 0).ok_or_else(|| RawError::malformed("ORF has no IFD0"))?;
let width = u64::from(t.tag_uint(&raw, tag::IMAGE_WIDTH).unwrap_or(0));
let height = u64::from(t.tag_uint(&raw, tag::IMAGE_LENGTH).unwrap_or(0));
let stored: u64 = t.tag_uints(&raw, tag::STRIP_BYTE_COUNTS).iter().map(|&c| u64::from(c)).sum();
// Every ORF, compressed or not, declares its strip byte counts. Missing or unreadable counts
// mean IFD0 is cut short (a partial download or a damaged file), not the compressed variant:
// an empty sum must not be mistaken for "fewer than 16 bits per sample".
let counts = t.tag_uints(&raw, tag::STRIP_BYTE_COUNTS);
if counts.is_empty() {
return Err(RawError::malformed("ORF IFD0 has no readable strip byte counts"));
}
let stored: u64 = counts.iter().map(|&c| u64::from(c)).sum();
if t.tag_uint(&raw, tag::COMPRESSION).unwrap_or(1) != 1 || stored < width.saturating_mul(height).saturating_mul(2) {
return Err(RawError::unsupported("Olympus compressed (or packed) ORF is not decoded"));
}
+27
View File
@@ -177,6 +177,33 @@ fn orf_uncompressed_round_trip() {
assert_eq!((p.width, p.height), (16, 8));
}
/// A file cut short inside IFD0 keeps its width, height and compression entries but loses the
/// strip byte counts; their empty sum used to read as "fewer than 16 bits per sample" and the
/// damaged file was reported as the undecoded compressed variant. Damage is `Malformed`,
/// whichever byte it lands on.
#[test]
fn orf_truncated_in_ifd0_is_malformed_not_the_compressed_variant() {
let (w, h) = (40, 24);
let data = mosaic(&scene(w, h), w, [1, 0, 2, 1], 64, 4095);
let b = orf(w, h, &data);
assert!(decode(&b, &Limits::default()).is_ok());
let ifd = u32::from_le_bytes(b[4..8].try_into().unwrap()) as usize;
let n = u16::from_le_bytes([b[ifd], b[ifd + 1]]) as usize;
let entries: Vec<u16> = (0..n).map(|i| u16::from_le_bytes([b[ifd + 2 + 12 * i], b[ifd + 3 + 12 * i]])).collect();
let at = |tag: u16| ifd + 2 + 12 * entries.iter().position(|&t| t == tag).unwrap();
// Cut right before the StripByteCounts entry: width, height and compression survive.
let cut = at(279);
assert!(cut > at(259), "compression precedes the byte counts");
assert!(matches!(decode(&b[..cut], &Limits::default()), Err(RawError::Malformed(_))), "{:?}", decode(&b[..cut], &Limits::default()).err());
// No prefix of the file reads as the compressed variant: the compression declaration is
// intact or gone, never "compressed".
for cut in 0..b.len() {
if let Err(RawError::Unsupported(m)) = decode(&b[..cut], &Limits::default()) {
assert!(!m.contains("compressed"), "prefix {cut}: {m}");
}
}
}
#[test]
fn orf_packed_or_compressed_falls_back() {
let (w, h) = (40, 24);