diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml
index 970c6577a4..56d1ad0831 100644
--- a/.github/workflows/freebsd.yml
+++ b/.github/workflows/freebsd.yml
@@ -32,5 +32,7 @@ jobs:
with:
craft-fonts-ref: abb83316d96aa59c1cf64784289e378fe9fa5695
cargo-build-extra-args: "--features heif"
- custom-test-command: "CARGO_PROFILE_DEV_DEBUG=0 cargo test --workspace --locked --features heif --exclude photocraft-ui-egui && CARGO_PROFILE_DEV_DEBUG=0 cargo test -p photocraft-ui-egui --locked --lib && CARGO_PROFILE_DEV_DEBUG=0 cargo test -p photocraft-ui-egui --locked --doc && CARGO_PROFILE_DEV_DEBUG=0 cargo test -p photocraft-ui-egui --locked --test '*' -- --test-threads=1"
+ # The VM's software Vulkan driver crashes (SIGSEGV) when several tests set up a device at
+ # once, so photocraft-gpu's tests run one at a time, like ui-egui's integration tests.
+ custom-test-command: "CARGO_PROFILE_DEV_DEBUG=0 cargo test --workspace --locked --features heif --exclude photocraft-ui-egui --exclude photocraft-gpu && CARGO_PROFILE_DEV_DEBUG=0 cargo test -p photocraft-gpu --locked -- --test-threads=1 && CARGO_PROFILE_DEV_DEBUG=0 cargo test -p photocraft-ui-egui --locked --lib && CARGO_PROFILE_DEV_DEBUG=0 cargo test -p photocraft-ui-egui --locked --doc && CARGO_PROFILE_DEV_DEBUG=0 cargo test -p photocraft-ui-egui --locked --test '*' -- --test-threads=1"
run-package-check: true
diff --git a/README.md b/README.md
index a80c702233..4285f69513 100644
--- a/README.md
+++ b/README.md
@@ -7,6 +7,10 @@
+
+
+
+
PhotoCraft
diff --git a/crates/affinity/src/access.rs b/crates/affinity/src/access.rs
index 0b60ed7dbd..732feeef49 100644
--- a/crates/affinity/src/access.rs
+++ b/crates/affinity/src/access.rs
@@ -20,6 +20,15 @@ impl Stream {
self.class(id) == Some(Tag::of(class))
}
+ /// The raw slot count of an object's array field (including null slots), for charging
+ /// traversal work before nulls are filtered out. `None` when the field is not an array.
+ pub fn array_len(&self, id: ObjId, tag: &[u8; 4]) -> Option {
+ match self.field(id, tag)? {
+ Value::Array(a) => Some(a.len()),
+ _ => None,
+ }
+ }
+
/// A child object (shared, linked or inline); `None` for null or missing.
pub fn obj(&self, id: ObjId, tag: &[u8; 4]) -> Option {
match self.field(id, tag)? {
diff --git a/crates/affinity/src/geometry.rs b/crates/affinity/src/geometry.rs
index 6c8ea602c9..6825abd511 100644
--- a/crates/affinity/src/geometry.rs
+++ b/crates/affinity/src/geometry.rs
@@ -210,7 +210,16 @@ fn anchors(records: &[Value]) -> Option> {
fn subpath(anchors: &[Anchor], closed: bool, radii: Option<&Vec<(usize, f64)>>) -> Option {
let first = anchors.first()?;
if let Some(radii) = radii.filter(|r| !r.is_empty()) {
- let corner = |a: &Anchor| radii.iter().filter(|(rec, _)| *rec == a.record).map(|(_, r)| *r).fold(0.0, f64::max);
+ // Index each record's maximum radius once. Scanning the whole radius list per anchor
+ // was O(anchors * radii), which a file with large anchor and radius arrays could drive
+ // into quadratic work. Duplicate record indices keep the maximum, matching the prior
+ // fold(0.0, f64::max) behavior.
+ let mut radius_by_record: std::collections::HashMap = std::collections::HashMap::with_capacity(radii.len());
+ for &(rec, r) in radii {
+ let slot = radius_by_record.entry(rec).or_insert(0.0);
+ *slot = slot.max(r);
+ }
+ let corner = |a: &Anchor| radius_by_record.get(&a.record).copied().unwrap_or(0.0);
let polyline = anchors.iter().all(|a| a.inh.is_none_or(|h| h == a.p) && a.out.is_none_or(|h| h == a.p));
if polyline {
let mut pts: Vec<(Point, f64)> = anchors.iter().map(|a| (a.p, corner(a))).collect();
diff --git a/crates/affinity/src/model.rs b/crates/affinity/src/model.rs
index 7c05072419..f77c970ae2 100644
--- a/crates/affinity/src/model.rs
+++ b/crates/affinity/src/model.rs
@@ -283,6 +283,12 @@ const MAX_TREE_DEPTH: usize = 128;
/// Total layer nodes imported.
const MAX_NODES: usize = 500_000;
+/// Total raw `Chld` slots inspected across the whole traversal, including null slots and slots
+/// reached through repeated references to a shared group. The node limit only counts produced
+/// nodes, so a shared group holding a large null-filled child array could still be rescanned
+/// many times; this bounds that work. Generous relative to MAX_NODES for real documents.
+const MAX_CHILD_SLOTS: usize = 8_000_000;
+
/// Read a document: archive, `doc.dat` and the model.
pub fn read(bytes: &[u8], limits: Limits) -> Result {
let mut archive = Archive::open(bytes, limits)?;
@@ -297,6 +303,7 @@ pub(crate) struct Reader<'s, 'a, 'b> {
pub(crate) dpi: f64,
warnings: BTreeMap,
nodes: usize,
+ child_slots: usize,
active: HashSet,
pub(crate) geometry_budget: crate::geometry::Budget,
/// Decoded pixels still available ([`Limits::max_pixel_bytes`] less what the layers read so
@@ -312,6 +319,7 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
dpi: 72.0,
warnings: BTreeMap::new(),
nodes: 0,
+ child_slots: 0,
active: HashSet::new(),
geometry_budget: crate::geometry::Budget::default(),
pixels_left: limits.max_pixel_bytes,
@@ -383,6 +391,13 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
let s = self.s;
let mut out = Vec::new();
let mut mask = None;
+ // Charge every raw child slot (null slots included) against a document-wide budget
+ // before filtering. A shared group with a large null-filled Chld array could otherwise
+ // be rescanned through each reference without the node limit noticing.
+ self.child_slots = self.child_slots.saturating_add(s.array_len(parent, b"Chld").unwrap_or(0));
+ if self.child_slots > MAX_CHILD_SLOTS {
+ return Err(Error::Limit("too many child slots in the layer tree"));
+ }
for c in s.objs(parent, b"Chld") {
if s.is(c, b"MRst") {
// A mask layer inside a group or layer masks that container.
diff --git a/crates/affinity/src/stream.rs b/crates/affinity/src/stream.rs
index a755747c45..2ffe12b7e5 100644
--- a/crates/affinity/src/stream.rs
+++ b/crates/affinity/src/stream.rs
@@ -73,11 +73,40 @@ pub struct Object {
pub chain: Vec,
/// Fields in stream order; positional fields have tag 0.
pub fields: Vec<(Tag, Value)>,
+ /// First-occurrence index of each non-positional tag, so `get` is O(1). A shared object
+ /// can be referenced by many sibling layers, and each reference used to rescan all of its
+ /// fields; indexing once keeps repeated lookups from multiplying into quadratic work.
+ index: std::collections::HashMap,
}
impl Object {
+ pub fn new(class: Tag, kind: Kind, chain: Vec, fields: Vec<(Tag, Value)>) -> Self {
+ let mut index = std::collections::HashMap::with_capacity(fields.len());
+ for (i, (t, _)) in fields.iter().enumerate() {
+ // Positional fields share tag 0 and are not looked up by tag; keep first-match
+ // semantics for real tags.
+ if t.0 != 0 {
+ index.entry(*t).or_insert(i);
+ }
+ }
+ Object { class, kind, chain, fields, index }
+ }
+
pub fn get(&self, tag: Tag) -> Option<&Value> {
- self.fields.iter().find(|(t, _)| *t == tag).map(|(_, v)| v)
+ self.index.get(&tag).map(|&i| &self.fields[i].1)
+ }
+
+ /// Replaces the fields (the root object is filled in after it is pushed) and rebuilds the
+ /// tag index so `get` stays correct.
+ fn set_fields(&mut self, fields: Vec<(Tag, Value)>) {
+ self.index.clear();
+ self.index.reserve(fields.len());
+ for (i, (t, _)) in fields.iter().enumerate() {
+ if t.0 != 0 {
+ self.index.entry(*t).or_insert(i);
+ }
+ }
+ self.fields = fields;
}
}
@@ -124,10 +153,10 @@ pub fn parse(bytes: &[u8]) -> Result {
c.u32()?;
}
let mut p = Parser { c, objects: Vec::new(), shared: std::collections::HashMap::new(), depth: 0, values: 0 };
- let root = p.push(Object { class, kind: Kind::Root, chain: vec![class], fields: Vec::new() })?;
+ let root = p.push(Object::new(class, Kind::Root, vec![class], Vec::new()))?;
let fields = p.fields(true, class)?;
if let Some(o) = p.objects.get_mut(root) {
- o.fields = fields;
+ o.set_fields(fields);
}
Ok(Stream { root, objects: p.objects })
}
@@ -252,7 +281,7 @@ impl Parser<'_> {
}
0x30 => {
let fields = self.fields(false, class)?;
- let id = self.push(Object { class, kind: Kind::Positional, chain: Vec::new(), fields })?;
+ let id = self.push(Object::new(class, Kind::Positional, Vec::new(), fields))?;
Value::Obj(Some(id))
}
0x31 | 0x32 if !tagged => return Err(Error::Malformed("object without a field tag")),
@@ -350,7 +379,7 @@ impl Parser<'_> {
}
};
let fields = self.fields(true, class)?;
- let id = self.push(Object { class, kind: Kind::Inline, chain: vec![class], fields })?;
+ let id = self.push(Object::new(class, Kind::Inline, vec![class], fields))?;
Ok(Value::Obj(Some(id)))
}
@@ -369,7 +398,7 @@ impl Parser<'_> {
return Err(Error::Malformed("object defined twice"));
}
// Register before the fields: an object's own fields may refer back to it.
- let id = self.push(Object { class: Tag(0), kind: Kind::Shared, chain: Vec::new(), fields: Vec::new() })?;
+ let id = self.push(Object::new(Tag(0), Kind::Shared, Vec::new(), Vec::new()))?;
self.shared.insert(key, id);
let mut chain = Vec::new();
let mut fields = Vec::new();
@@ -395,7 +424,7 @@ impl Parser<'_> {
let class = chain.first().copied().unwrap_or(Tag(0));
fields.extend(self.fields(true, class)?);
if let Some(o) = self.objects.get_mut(id) {
- *o = Object { class, kind: Kind::Shared, chain, fields };
+ *o = Object::new(class, Kind::Shared, chain, fields);
}
Ok(Value::Obj(Some(id)))
}
diff --git a/crates/cms/src/clut.rs b/crates/cms/src/clut.rs
index 186cd3f9f1..38992a2ed4 100644
--- a/crates/cms/src/clut.rs
+++ b/crates/cms/src/clut.rs
@@ -172,11 +172,16 @@ impl Clut {
}
let o = self.outputs;
out[..o].iter_mut().for_each(|v| *v = 0.0);
- for corner in 0..(1usize << n) {
+ // Only dimensions with more than one grid point contribute a cube axis. Degenerate
+ // dimensions have step 0 and ratio 0, so they always multiply the corner weight by 1
+ // and never change the offset; enumerating 2^n corners over all declared inputs would
+ // do 2^(degenerate) redundant iterations. Interpolate over the effective dims only.
+ let eff: Vec = (0..n).filter(|&d| s[d] != 0).collect();
+ for corner in 0..(1usize << eff.len()) {
let mut w = 1.0f32;
let mut off = base;
- for d in 0..n {
- if corner & (1 << d) != 0 {
+ for (bit, &d) in eff.iter().enumerate() {
+ if corner & (1 << bit) != 0 {
w *= r[d];
off += s[d];
} else {
@@ -245,4 +250,20 @@ mod tests {
c.eval(&[0.5, 0.25], &mut o);
assert!((o[0] - 0.75).abs() < 1e-6);
}
+
+ #[test]
+ fn degenerate_dimensions_do_not_change_the_result() {
+ // Dimensions with a single grid point contribute nothing, but the old code still
+ // enumerated 2^inputs corners over them. Interpolating over effective dims only must
+ // give the same answer as a reference built without the degenerate axes.
+ let f = |p: &[f32], o: &mut [f32]| o[0] = 0.25 * p[0] + 0.5 * p[2] + 0.125 * p[4];
+ // Axes 1 and 3 have a single grid point (degenerate); axes 0, 2, 4 vary.
+ let c = Clut::sample(vec![3, 1, 4, 1, 5], 1, f);
+ let mut got = [0.0];
+ for p in [[0.2f32, 0.0, 0.7, 0.0, 0.4], [1.0, 0.0, 0.0, 0.0, 1.0], [0.5, 0.0, 0.5, 0.0, 0.5]] {
+ c.eval_multilinear(&p, &mut got);
+ let want = 0.25 * p[0] + 0.5 * p[2] + 0.125 * p[4];
+ assert!((got[0] - want).abs() < 1e-5, "got {} want {want}", got[0]);
+ }
+ }
}
diff --git a/crates/cms/src/curve.rs b/crates/cms/src/curve.rs
index fe23e6e059..8b619842d1 100644
--- a/crates/cms/src/curve.rs
+++ b/crates/cms/src/curve.rs
@@ -99,6 +99,20 @@ impl Curve {
(0..n).map(|i| self.eval64(i as f64 / (n - 1) as f64) as f32).collect()
}
+ /// Inverse of the curve at each `y` in `ys`. For sampled-table curves the flat-run edge
+ /// indices are computed once for the whole batch instead of rescanning the table on every
+ /// call, so inverting a long constant table many times stays linear.
+ pub fn inverse_table(&self, ys: impl Iterator) -> Vec {
+ match self {
+ Curve::Table(t) if t.len() >= 2 => {
+ let increasing = t[t.len() - 1] >= t[0];
+ let (edge_start, edge_end) = (edge_index(t, true), edge_index(t, false));
+ ys.map(|y| table_inverse_with(t, y as f32, increasing, edge_start, edge_end)).collect()
+ }
+ _ => ys.map(|y| self.eval_inverse64(y) as f32).collect(),
+ }
+ }
+
/// `true` when the curve never decreases over `[0, 1]`.
pub fn is_monotonic(&self) -> bool {
let s = self.sample(1024);
@@ -244,13 +258,24 @@ fn table_inverse(t: &[f32], y: f32) -> f32 {
return y;
}
let increasing = t[n - 1] >= t[0];
+ table_inverse_with(t, y, increasing, edge_index(t, true), edge_index(t, false))
+}
+
+/// [`table_inverse`] with the flat-run edge indices supplied. Computing those edges scans the
+/// whole table, and transform construction inverts the same table thousands of times, so the
+/// edges are computed once and reused across a batch (see [`Curve::inverse_table`]).
+fn table_inverse_with(t: &[f32], y: f32, increasing: bool, edge_start: f32, edge_end: f32) -> f32 {
+ let n = t.len();
+ if n < 2 {
+ return y;
+ }
let (lo_v, hi_v) = if increasing { (t[0], t[n - 1]) } else { (t[n - 1], t[0]) };
if y <= lo_v {
// Flat start: choose the last index still at the lowest value.
- return if increasing { edge_index(t, true) } else { edge_index(t, false) };
+ return if increasing { edge_start } else { edge_end };
}
if y >= hi_v {
- return if increasing { edge_index(t, false) } else { edge_index(t, true) };
+ return if increasing { edge_end } else { edge_start };
}
let (mut lo, mut hi) = (0usize, n - 1);
while hi - lo > 1 {
@@ -336,4 +361,24 @@ mod tests {
let g = Curve::Gamma(2.2);
assert!((g.eval64(-0.5) + 0.5f64.powf(2.2)).abs() < 1e-12);
}
+
+ #[test]
+ fn inverse_table_matches_per_call_including_flat_runs() {
+ // Batch inverse must equal calling eval_inverse64 per point. Include a table with flat
+ // runs at both ends (the case that used to rescan the table on every call).
+ let tables = [
+ Curve::Table(vec![0.0, 0.0, 0.0, 0.25, 0.5, 1.0, 1.0, 1.0]),
+ Curve::Table(vec![0.0, 0.2, 0.4, 0.6, 0.8, 1.0]),
+ Curve::Table(vec![1.0, 0.5, 0.0]),
+ Curve::Gamma(2.2),
+ ];
+ let ys: Vec = (0..=40).map(|i| i as f64 / 40.0).collect();
+ for c in &tables {
+ let batch = c.inverse_table(ys.iter().copied());
+ for (i, &y) in ys.iter().enumerate() {
+ let one = c.eval_inverse64(y) as f32;
+ assert!((batch[i] - one).abs() < 1e-6, "y={y}: batch {} vs per-call {one}", batch[i]);
+ }
+ }
+ }
}
diff --git a/crates/cms/src/transform.rs b/crates/cms/src/transform.rs
index 04d11247f4..f30c17c82e 100644
--- a/crates/cms/src/transform.rs
+++ b/crates/cms/src/transform.rs
@@ -133,12 +133,11 @@ impl Transform {
let tables = cs
.iter()
.map(|c| {
- (0..OUT_TABLE)
- .map(|i| {
- let u = i as f64 / (OUT_TABLE - 1) as f64;
- c.eval_inverse64(u * u * u * u).clamp(0.0, 1.0) as f32
- })
- .collect()
+ let ys = (0..OUT_TABLE).map(|i| {
+ let u = i as f64 / (OUT_TABLE - 1) as f64;
+ u * u * u * u
+ });
+ c.inverse_table(ys).into_iter().map(|v| v.clamp(0.0, 1.0)).collect()
})
.collect();
post = Some(Post { tables });
diff --git a/crates/codecs/src/codecs/deep_exr.rs b/crates/codecs/src/codecs/deep_exr.rs
index 29f3762ebc..4fe9def532 100644
--- a/crates/codecs/src/codecs/deep_exr.rs
+++ b/crates/codecs/src/codecs/deep_exr.rs
@@ -341,15 +341,17 @@ pub(crate) fn decode_deep(meta: &MetaData, bytes: &[u8], limits: &Limits) -> Res
return Err(err(format!("chunk declares part {part}, expected {header_index}")));
}
}
- // Block coordinates → the block's global pixel range and its slot.
- let (block_pixels, slot, row_w): (Vec, usize, usize) = match &header.blocks {
+ // Block coordinates → the block's pixel bounds and its slot. The per-pixel index is
+ // built only after the chunk's declared payload sizes are validated below, so a
+ // truncated or lying chunk cannot drive a large index allocation first.
+ let (block_rows, block_cols, slot, row_w, scanline_row): (std::ops::Range, std::ops::Range, usize, usize, bool) = match &header.blocks {
BlockDescription::ScanLines => {
let y = i32(&mut cur)?;
let row = match y.checked_sub(y0) {
Some(d) if d >= 0 && (d as usize) < h => d as usize,
_ => return Err(err(format!("scan line block y={y} is outside the data window"))),
};
- ((0..w).map(|x| row * w + x).collect(), row, w)
+ (row..row + 1, 0..w, row, w, true)
}
BlockDescription::Tiles(_) => {
let (tx, ty, lx, ly) = (i32(&mut cur)?, i32(&mut cur)?, i32(&mut cur)?, i32(&mut cur)?);
@@ -362,16 +364,11 @@ pub(crate) fn decode_deep(meta: &MetaData, bytes: &[u8], limits: &Limits) -> Res
let (bx, by) = ((tx as usize) * tile_w, (ty as usize) * tile_h);
let rows = (by + tile_h).min(h);
let cols = (bx + tile_w).min(w);
- let mut px = Vec::with_capacity((rows - by) * (cols - bx));
- for yy in by..rows {
- for xx in bx..cols {
- px.push(yy.saturating_mul(w).saturating_add(xx));
- }
- }
let slot = (ty as usize).saturating_mul(tiles_x) + tx as usize;
- (px, slot, cols - bx)
+ (by..rows, bx..cols, slot, cols - bx, false)
}
};
+ let block_len = block_rows.len().saturating_mul(block_cols.len());
if seen.get(slot).copied().unwrap_or(true) {
return Err(err(format!("two chunks cover block {slot}")));
}
@@ -386,6 +383,20 @@ pub(crate) fn decode_deep(meta: &MetaData, bytes: &[u8], limits: &Limits) -> Res
if packed_table > remaining || packed_data > remaining.saturating_sub(packed_table) {
return Err(err("deep chunk sizes point past the end of the file"));
}
+ // The chunk's payload is present; now materialize the block's global pixel index. For
+ // scan lines this is one row; for tiles the clipped tile rectangle.
+ let block_pixels: Vec = if scanline_row {
+ let row = block_rows.start;
+ (0..w).map(|x| row * w + x).collect()
+ } else {
+ let mut px = Vec::with_capacity(block_len);
+ for yy in block_rows.clone() {
+ for xx in block_cols.clone() {
+ px.push(yy.saturating_mul(w).saturating_add(xx));
+ }
+ }
+ px
+ };
let what = "the sample-count table";
let table_bytes = read_exact(&mut cur, packed_table as usize, what)?;
let expected_table = block_pixels.len().checked_mul(4).ok_or_else(|| err("count table overflows"))?;
@@ -552,7 +563,7 @@ fn header_end(bytes: &[u8]) -> Result {
/// into straight alpha at the end. Without any alpha channel the front-most sample wins,
/// opaque. Z, ZBack and other deep channels influence the flat result only through the
/// ordering; volume samples (with ZBack) are approximated as point samples.
-pub(crate) fn flatten(deep: &DeepImage) -> Result {
+pub(crate) fn flatten(deep: &DeepImage, limits: &Limits) -> Result {
let npx = deep.width as usize * deep.height as usize;
let has = |n: &str| deep.channel(n).is_some();
let rgb = has("R") && has("G") && has("B");
@@ -569,6 +580,10 @@ pub(crate) fn flatten(deep: &DeepImage) -> Result {
return Err(CodecError::unsupported(F, "no R/G/B or Y channels in the deep part"));
};
let nc = layout.channels();
+ // Admit the flattened output at its true size: nc f32 samples per pixel. The deep decode
+ // admitted the data window at four bytes per pixel, before the flattened channel layout
+ // was known, so re-check here with the actual layout and f32 sample size.
+ limits.check_bytes(deep.width, deep.height, nc as u64 * 4)?;
let samples = |n: &str| -> &[f32] { deep.channel(n).map(|c| c.samples.as_slice()).unwrap_or(&[]) };
let (r, g, b) = (samples("R"), samples("G"), samples("B"));
let (ar, ag, ab) = (samples("AR"), samples("AG"), samples("AB"));
@@ -665,6 +680,10 @@ pub(crate) fn flatten(deep: &DeepImage) -> Result {
// pads to the stage at its offset, transparent elsewhere (limits validated the size).
let mut img = match deep.stage {
Some((sw, sh)) => {
+ // The display stage can be larger than the data window; admit it at the real f32
+ // layout size before allocating, since the earlier deep-decode check used four
+ // bytes per pixel and did not know the flattened channel count.
+ limits.check_bytes(sw, sh, nc as u64 * 4)?;
let mut padded = vec![0f32; sw as usize * sh as usize * nc];
let row_len = deep.width as usize * nc;
let dx = deep.origin.0.max(0) as usize;
diff --git a/crates/codecs/src/codecs/exr.rs b/crates/codecs/src/codecs/exr.rs
index 1b268ac559..2b32a2b7c4 100644
--- a/crates/codecs/src/codecs/exr.rs
+++ b/crates/codecs/src/codecs/exr.rs
@@ -164,7 +164,7 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result
// composite the samples into the flat image (a warning says what was lost).
if meta.requirements.has_deep_data {
let deep = super::deep_exr::decode_deep(&meta, bytes, limits)?;
- return super::deep_exr::flatten(&deep);
+ return super::deep_exr::flatten(&deep, limits);
}
let pick = pick_part(&meta);
diff --git a/crates/codecs/src/codecs/exr_cryptomatte.rs b/crates/codecs/src/codecs/exr_cryptomatte.rs
index 5f13f98621..370c7c7e56 100644
--- a/crates/codecs/src/codecs/exr_cryptomatte.rs
+++ b/crates/codecs/src/codecs/exr_cryptomatte.rs
@@ -156,9 +156,13 @@ pub(crate) fn layers(bytes: &[u8], limits: &Limits) -> Result> = std::collections::HashMap::new();
for key in cryptomatte_keys(&header.own_attributes.other) {
let Some(name) = attribute(&header.own_attributes.other, &key, "name") else { continue };
- let channels = layer_streams(header, name.as_str());
+ let channels = streams_for.entry(name.clone()).or_insert_with(|| layer_streams(header, name.as_str())).clone();
if channels.is_empty() {
continue;
}
@@ -184,11 +188,15 @@ pub(crate) fn layers(bytes: &[u8], limits: &Limits) -> Result/` key of a header, in file order, without duplicates.
fn cryptomatte_keys(other: &std::collections::HashMap) -> Vec {
let mut keys = Vec::new();
+ // Track seen keys in a set: the attribute names are file-controlled, so membership with
+ // keys.iter().any() is O(n^2) in the number of distinct `cryptomatte//`
+ // groups. The Vec still records file order; the set only answers "seen before".
+ let mut seen = std::collections::HashSet::new();
for name in other.keys() {
let full = name.to_string();
let Some(rest) = full.strip_prefix("cryptomatte/") else { continue };
let Some((key, _field)) = rest.split_once('/') else { continue };
- if !key.is_empty() && !keys.iter().any(|k| k == key) {
+ if !key.is_empty() && seen.insert(key.to_string()) {
keys.push(key.to_string());
}
}
@@ -217,14 +225,18 @@ pub(crate) fn decode(bytes: &[u8], layer_name: &str, limits: &Limits) -> Result<
let too_large = || CodecError::malformed(F, "the layer is too large");
let (w32, h32) = (u32::try_from(w).map_err(|_| too_large())?, u32::try_from(h).map_err(|_| too_large())?);
// Each pixel also gets a pair list, so count its header with the channel samples (four
- // f32 channels per set: two ranks of ID and coverage).
- let per_pixel = (layer.channels.len().max(1) as u64).saturating_mul(16).saturating_add(std::mem::size_of::>() as u64);
+ // f32 channels per set: two ranks of ID and coverage). Include the heap capacity the pair
+ // list can reach: up to one (id, coverage) pair per rank, 8 bytes each. Without this the
+ // returned per-pixel vectors could exceed the caller's max_alloc budget.
+ let ranks = layer.channels.len().saturating_mul(2);
+ let pair_capacity = (ranks as u64).saturating_mul(std::mem::size_of::<(f32, f32)>() as u64);
+ let per_pixel =
+ (layer.channels.len().max(1) as u64).saturating_mul(16).saturating_add(std::mem::size_of::>() as u64).saturating_add(pair_capacity);
limits.check_bytes(w32, h32, per_pixel)?;
let ex_layer = super::exr::read_layer(bytes, layer.part)?;
// Two (ID, coverage) ranks per set: rank `slot * 2 + stream.rank`.
- let ranks = layer.channels.len().saturating_mul(2);
let mut ids: Vec> = vec![Vec::new(); ranks];
let mut coverages: Vec> = vec![Vec::new(); ranks];
for c in &ex_layer.channel_data.list {
diff --git a/crates/codecs/src/codecs/fits.rs b/crates/codecs/src/codecs/fits.rs
index 7e3f92ae67..214d968944 100644
--- a/crates/codecs/src/codecs/fits.rs
+++ b/crates/codecs/src/codecs/fits.rs
@@ -22,6 +22,8 @@
//! (strings longer than one card on `CONTINUE` cards).
//! Tile-compressed files (fpack `ZIMAGE` tables) are recognised and refused.
+use std::collections::HashMap;
+
use crate::Format;
use crate::error::CodecError;
use crate::fidelity::Plan;
@@ -106,13 +108,19 @@ fn parse_card(raw: &[u8]) -> Card {
struct Header {
cards: Vec,
+ /// First-occurrence index of each non-HIERARCH keyword, so `get` is O(1). The card
+ /// count and the number of looked-up keywords (NAXIS1..NAXISn) are both file-controlled,
+ /// so a linear scan per lookup lets a crafted header multiply commentary cards by axis
+ /// count into quadratic work.
+ index: HashMap,
/// Offset of the data unit.
data_start: usize,
}
impl Header {
fn get(&self, key: &str) -> Option<&Value> {
- self.cards.iter().find(|c| !c.hierarch && c.key == key).and_then(|c| c.value.as_ref())
+ let c = &self.cards[*self.index.get(key)?];
+ c.value.as_ref()
}
fn int(&self, key: &str) -> Option {
@@ -167,7 +175,15 @@ fn parse_header(b: &[u8], start: usize) -> Result {
cards.push(card);
}
let data_start = pos.div_ceil(BLOCK).checked_mul(BLOCK).ok_or_else(|| err("header too long"))?;
- Ok(Header { cards, data_start })
+ // Index each non-HIERARCH keyword to its first occurrence, matching the previous
+ // `find(|c| !c.hierarch && c.key == key)` lookup while making `get` O(1).
+ let mut index = HashMap::with_capacity(cards.len());
+ for (i, c) in cards.iter().enumerate() {
+ if !c.hierarch {
+ index.entry(c.key.clone()).or_insert(i);
+ }
+ }
+ Ok(Header { cards, index, data_start })
}
/// The axis lengths (`NAXIS1`, `NAXIS2`, …).
diff --git a/crates/codecs/src/codecs/heif.rs b/crates/codecs/src/codecs/heif.rs
index 31da309ca3..efd9d2073a 100644
--- a/crates/codecs/src/codecs/heif.rs
+++ b/crates/codecs/src/codecs/heif.rs
@@ -45,6 +45,13 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits, keep_orientation: bool) -> R
let sample = if info.sixteen_bit { SampleType::U16 } else { SampleType::U8 };
let (w, h) = if keep_orientation { (info.coded_width, info.coded_height) } else { (info.width, info.height) };
limits.check(w, h, layout, sample)?;
+ // The delegated decoder allocates the full coded image before applying any clean-aperture
+ // crop or orientation transform, so the coded dimensions must pass the limit policy even
+ // when the displayed (transformed) size is small. Checking only the transformed size let a
+ // tiny visible image hide a large coded allocation.
+ if !keep_orientation && (info.coded_width, info.coded_height) != (w, h) {
+ limits.check(info.coded_width, info.coded_height, layout, sample)?;
+ }
let options = photocraft_heif::Options { max_pixels: limits.max_pixels, apply_transforms: !keep_orientation };
let decoded = photocraft_heif::decode(bytes, &options).map_err(err)?;
// The decoder re-reads the container; trust its output's shape, not the probe's.
diff --git a/crates/codecs/src/resolution.rs b/crates/codecs/src/resolution.rs
index cb86001ac9..a787cacbc3 100644
--- a/crates/codecs/src/resolution.rs
+++ b/crates/codecs/src/resolution.rs
@@ -429,8 +429,14 @@ fn xmp_values(xmp: &str, key: &str) -> Vec<(usize, usize)> {
continue;
};
let start = xmp.len() - value.len();
- let Some(len) = value.find(close) else { continue };
+ // Advance past this value before searching for the next key. Without this, `from`
+ // only moved past the key text, so property-like text repeated inside an unrelated
+ // attribute re-ran this full-suffix `find(close)` for every occurrence, which is
+ // quadratic in the packet size. A missing close means the value is unterminated, so
+ // no later value can parse either; stop scanning in that case.
+ let Some(len) = value.find(close) else { break };
out.push((start, start + len));
+ from = start + len + close.len_utf8();
}
out
}
diff --git a/crates/codecs/tests/resolution.rs b/crates/codecs/tests/resolution.rs
index 15ddc95749..28c8203c96 100644
--- a/crates/codecs/tests/resolution.rs
+++ b/crates/codecs/tests/resolution.rs
@@ -284,6 +284,17 @@ fn truncated_metadata_never_panics_and_falls_back() {
assert_eq!(xmp_resolution(x), None, "{x}");
let _ = xmp_with_resolution(x, (1.0, 1.0));
}
+
+ // Property-like text repeated inside the packet must not trigger a full-suffix rescan
+ // per occurrence (it used to be quadratic). With the cursor advancing past each located
+ // value, this completes well under a second even with many decoys; a timeout here would
+ // indicate the quadratic scan returned. A clean packet still extracts correctly.
+ let start = std::time::Instant::now();
+ let decoys = " tiff:ResolutionUnit>".repeat(50_000);
+ let _ = xmp_resolution(&decoys);
+ assert!(start.elapsed() < std::time::Duration::from_secs(2), "xmp scan took too long: {:?}", start.elapsed());
+ let clean = "300/1300/12";
+ near(xmp_resolution(clean), 300.0);
}
proptest! {
diff --git a/crates/compose/src/masks.rs b/crates/compose/src/masks.rs
index 802a0f2df9..7335be90d0 100644
--- a/crates/compose/src/masks.rs
+++ b/crates/compose/src/masks.rs
@@ -221,7 +221,14 @@ fn build_mask(layer: &Layer, canvas: Rect) -> Surface {
}
let area = area.intersect(&canvas);
let mut s = Surface::with_default(FORMAT, &[p_def * v_out]);
- if !area.is_empty() {
+ // Bound the transient dense working set: `area` is derived from sparse mask geometry grown
+ // by feather, so two distant changed pixels could span a canvas-sized rectangle. Building
+ // f32 planes over it (coverage, pixel-mask values, blur scratch) could be gigabytes during
+ // a one-pixel render. Above the budget, fall back to the constant default mask rather than
+ // allocating; feather detail is lost only for pathological geometry.
+ const MAX_MASK_WORKING_BYTES: u64 = 512 << 20;
+ let working = (area.width() as u64).saturating_mul(area.height() as u64).saturating_mul(std::mem::size_of::() as u64).saturating_mul(3);
+ if !area.is_empty() && working <= MAX_MASK_WORKING_BYTES {
let (w, h) = (area.width() as usize, area.height() as usize);
// A slot initializer must not enter Rayon: another tile requesting the
// same mask could be stolen onto its stack and wait for its own build.
diff --git a/crates/compose/src/pattern.rs b/crates/compose/src/pattern.rs
index d107ecf4e7..b66ab0ad1e 100644
--- a/crates/compose/src/pattern.rs
+++ b/crates/compose/src/pattern.rs
@@ -12,6 +12,10 @@ use photocraft_doc::{ColorMode, Pattern, PixelFormat, Rect, SampleType};
pub(crate) const PREPARED_PATTERN_BYTES: usize = 64 << 20;
const MAX_PREPARED_PATTERNS: usize = 64;
+/// Absolute cap on a single pattern's RGBA conversion on the uncached path. A pattern this
+/// large cannot be cached, so re-expanding it for every output tile would be a DoS; above the
+/// cap it is skipped instead. Independent of the cache budget so small patterns still convert.
+const MAX_UNCACHED_PATTERN_BYTES: usize = 256 << 20;
type PreparedSlot = Arc>>>;
struct PreparedState {
@@ -78,6 +82,13 @@ impl<'a> PreparedPatterns<'a> {
// Run on the requesting worker to preserve its active CMYK profile.
slot.get_or_init(|| Tile::new_in(p, self.mode).map(Arc::new)).clone()
}
+ // Uncached path: a pattern whose full conversion would exceed an absolute cap is
+ // not materialized at all. Otherwise every output tile would re-expand the entire
+ // sparse pattern into RGBA floats (and could do so concurrently), exhausting CPU and
+ // memory even though cache admission already refused to retain it. The cap is
+ // independent of the (possibly zero) cache budget, so small patterns still convert
+ // on demand when nothing is cached.
+ None if bytes.is_none_or(|b| b > MAX_UNCACHED_PATTERN_BYTES) => None,
None => Tile::new_in(p, self.mode).map(Arc::new),
}
}
diff --git a/crates/doc/src/slices.rs b/crates/doc/src/slices.rs
index e22fcc0b79..7fb6ba3ad5 100644
--- a/crates/doc/src/slices.rs
+++ b/crates/doc/src/slices.rs
@@ -202,24 +202,42 @@ pub fn auto_slices(canvas: Rect, taken: &[Rect]) -> Vec {
xs.dedup();
ys.sort_unstable();
ys.dedup();
- let covered = |x0: i32, y0: i32| taken.iter().any(|r| x0 >= r.x0 && x0 < r.x1 && y0 >= r.y0 && y0 < r.y1);
- // Horizontal runs per row band.
+ // Clip the taken rectangles to the canvas once, so band coverage only considers rects that
+ // can actually cover a cell.
+ let clipped: Vec = taken.iter().map(|r| r.intersect(&canvas)).filter(|r| !r.is_empty()).collect();
+ // Map an x edge to its cell index in `xs` (xs is sorted/deduped).
+ let x_cell = |x: i32| xs.partition_point(|&e| e <= x).saturating_sub(1);
+ // Horizontal runs per row band. For each band, mark covered x-cells in a single pass over
+ // the rects intersecting that band, instead of scanning every rect for every cell (which
+ // was cubic in the slice count and let a crafted document stall canvas drawing).
+ let ncells = xs.len().saturating_sub(1);
let mut runs: Vec = Vec::new();
for yw in ys.windows(2) {
+ let (band_y0, band_y1) = (yw[0], yw[1]);
+ let mut covered_cell = vec![false; ncells];
+ for r in &clipped {
+ if r.y0 <= band_y0 && band_y1 <= r.y1 {
+ // Rect spans this whole band vertically; mark the x-cells it covers.
+ let (c0, c1) = (x_cell(r.x0), x_cell(r.x1));
+ for c in covered_cell.iter_mut().take(c1).skip(c0) {
+ *c = true;
+ }
+ }
+ }
let mut start: Option = None;
- for xw in xs.windows(2) {
- let free = !covered(xw[0], yw[0]);
+ for (ci, xw) in xs.windows(2).enumerate() {
+ let free = !covered_cell.get(ci).copied().unwrap_or(false);
match (free, start) {
(true, None) => start = Some(xw[0]),
(false, Some(s)) => {
- runs.push(Rect::new(s, yw[0], xw[0], yw[1]));
+ runs.push(Rect::new(s, band_y0, xw[0], band_y1));
start = None;
}
_ => {}
}
}
if let Some(s) = start {
- runs.push(Rect::new(s, yw[0], *xs.last().unwrap_or(&canvas.x1), yw[1]));
+ runs.push(Rect::new(s, band_y0, *xs.last().unwrap_or(&canvas.x1), band_y1));
}
}
// Merge vertically adjacent runs with the same columns.
@@ -299,6 +317,53 @@ mod tests {
assert_eq!(auto_slices(canvas, &[]), vec![canvas]);
}
+ #[test]
+ fn auto_slices_cover_exactly_with_many_overlapping_slices() {
+ // Several overlapping slices form a non-trivial grid. The per-band coverage pass must
+ // still tile the complement exactly: auto area + taken-union area == canvas, with no
+ // auto slice overlapping a taken rect or another auto slice.
+ let canvas = Rect::new(0, 0, 200, 150);
+ let taken = [Rect::new(10, 10, 90, 70), Rect::new(50, 40, 160, 120), Rect::new(120, 0, 200, 50), Rect::new(0, 100, 60, 150)];
+ let auto = auto_slices(canvas, &taken);
+ // No auto slice intersects any taken rect.
+ for a in &auto {
+ for t in &taken {
+ assert!(a.intersect(t).is_empty(), "{a:?} overlaps taken {t:?}");
+ }
+ }
+ // Auto slices do not overlap each other.
+ for (i, a) in auto.iter().enumerate() {
+ for b in &auto[i + 1..] {
+ assert!(a.intersect(b).is_empty(), "{a:?} overlaps {b:?}");
+ }
+ }
+ // Auto area equals the canvas minus the union of taken rects (computed on the same grid).
+ let union_cells: i64 = {
+ // Sum cell areas covered by at least one taken rect, over the fine grid.
+ let mut xs = vec![canvas.x0, canvas.x1];
+ let mut ys = vec![canvas.y0, canvas.y1];
+ for t in &taken {
+ xs.extend([t.x0, t.x1]);
+ ys.extend([t.y0, t.y1]);
+ }
+ xs.sort_unstable();
+ xs.dedup();
+ ys.sort_unstable();
+ ys.dedup();
+ let mut sum = 0i64;
+ for yw in ys.windows(2) {
+ for xw in xs.windows(2) {
+ let covered = taken.iter().any(|r| xw[0] >= r.x0 && xw[0] < r.x1 && yw[0] >= r.y0 && yw[0] < r.y1);
+ if covered {
+ sum += i64::from(xw[1] - xw[0]) * i64::from(yw[1] - yw[0]);
+ }
+ }
+ }
+ sum
+ };
+ assert_eq!(area(&auto) + union_cells, i64::from(canvas.width()) * i64::from(canvas.height()));
+ }
+
#[test]
fn resolve_numbers_top_to_bottom() {
let mut d = Document::new("site.psd", Size::new(100, 80), ColorMode::Rgb, SampleType::U8);
diff --git a/crates/engine/src/actions_cmds.rs b/crates/engine/src/actions_cmds.rs
index f831d63878..6f559a5659 100644
--- a/crates/engine/src/actions_cmds.rs
+++ b/crates/engine/src/actions_cmds.rs
@@ -81,6 +81,8 @@ pub struct ActionsFile {
pub actions: Vec,
}
+pub const MAX_PLAYBACK_STEPS: u64 = 100_000;
+
/// Actions list and the recording / playback flags. Only `list` is persisted.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
@@ -95,6 +97,9 @@ pub struct ActionState {
/// Active action names, for cycle detection in engine and desktop playback.
#[serde(skip)]
pub playback_stack: Vec,
+ /// Number of steps executed by the current top-level playback tree.
+ #[serde(skip)]
+ pub playback_steps: u64,
/// Bumped whenever `list` changes, so the preset store can skip unchanged writes.
#[serde(skip)]
pub rev: u64,
@@ -143,6 +148,15 @@ pub fn save_target_closed(s: &Session, id: &str, target: Option bool {
+ if s.actions.playback_steps >= MAX_PLAYBACK_STEPS {
+ false
+ } else {
+ s.actions.playback_steps += 1;
+ true
+ }
+}
/// View-only menu commands recorded by the desktop shell. Headless playback reports
/// them as unsupported instead of silently dropping them; Batch and droplets skip them.
pub fn shell_view_command(id: &str) -> bool {
@@ -348,6 +362,10 @@ fn run_recorded(s: &mut Session, action: &Action, from: usize) -> (u64, Option (u64, Option Result