From 2ead0afaa0b941cecbb3b46f2c7ff7f060d22c0f Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 4 Nov 2026 02:42:44 +0000 Subject: [PATCH] affinity import: share one work and name budget across repeated links of shared objects --- crates/affinity/src/model/read.rs | 42 +++++++++++++++++++++++++++++-- crates/affinity/src/paint.rs | 9 ++++--- crates/affinity/src/raster.rs | 9 ++++++- crates/affinity/src/text.rs | 19 +++++++++++--- 4 files changed, 68 insertions(+), 11 deletions(-) diff --git a/crates/affinity/src/model/read.rs b/crates/affinity/src/model/read.rs index 00910baab..80f4e0993 100644 --- a/crates/affinity/src/model/read.rs +++ b/crates/affinity/src/model/read.rs @@ -29,6 +29,14 @@ const MAX_DOCUMENT_CELLS: usize = 1 << 18; /// Points of all strokes' pressure curves together (a stroke style may be shared by many layers). const MAX_DOCUMENT_PRESSURE_POINTS: usize = 1 << 20; +/// Child references inspected, page rectangles produced, and outline segments retained, in all +/// (shared objects are visited once per link, so each visit is charged). Real documents stay +/// within a few times [`MAX_NODES`]; this leaves ~30x headroom over that. +const MAX_DOCUMENT_WORK: usize = 1 << 24; + +/// Owned layer-name characters kept in all (each visit of a shared object keeps its own copy). +const MAX_DOCUMENT_NAME_CHARS: usize = 16 << 20; + /// What a whole read may still use. Shared objects can be linked from many layers, and embedded /// documents are read with what their embedding document left, so linking or embedding the /// same content many times can't multiply the work. @@ -44,6 +52,10 @@ pub(crate) struct Budget { pub(crate) cells: usize, /// Points of pressure curves ([`MAX_DOCUMENT_PRESSURE_POINTS`] in all). pub(crate) pressure: usize, + /// Child references, page rectangles and outline segments ([`MAX_DOCUMENT_WORK`] in all). + pub(crate) work: usize, + /// Layer-name characters ([`MAX_DOCUMENT_NAME_CHARS`] in all). + pub(crate) name_chars: usize, } impl Budget { @@ -53,7 +65,15 @@ impl Budget { chars: MAX_DOCUMENT_CHARS, cells: MAX_DOCUMENT_CELLS, pressure: MAX_DOCUMENT_PRESSURE_POINTS, + work: MAX_DOCUMENT_WORK, + name_chars: MAX_DOCUMENT_NAME_CHARS, }; + + /// Take `n` units of work, or fail the read. + pub(crate) fn spend(&mut self, n: usize) -> Result<(), Error> { + self.work = self.work.checked_sub(n).ok_or(Error::Limit("too much shared geometry or page data"))?; + Ok(()) + } } /// Read a document: archive, `doc.dat` and the model. @@ -192,6 +212,7 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> { return Ok(None); } let pages = pages(s, spread); + self.budget.spend(pages.len() + 1)?; let default_size = s.floats::<2>(doc, b"DfSz").filter(|[w, h]| *w > 0.0 && *h > 0.0).map(|[w, h]| Rect { x0: 0.0, y0: 0.0, x1: w, y1: h }); let bounds = s.floats::<4>(spread, b"SprB").map(rect).or_else(|| pages.iter().copied().reduce(union)).or(default_size).unwrap_or(Rect { x0: 0.0, @@ -214,7 +235,10 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> { let s = self.s; let mut out = Vec::new(); let mut mask = None; - for c in s.objs(parent, b"Chld") { + let kids = s.objs(parent, b"Chld"); + // Invisible masks and skipped children cost no node, so the visit is charged here. + self.budget.spend(kids.len())?; + for c in kids { if s.is(c, b"MRst") { // A mask layer inside a group or layer masks that container. if s.bool(c, b"Visi") == Some(false) { @@ -254,7 +278,8 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> { let world = local.then(parent); let class = s.class(id).unwrap_or(Tag(0)); // A layer name is short; a long one is cut, as every visit of a shared object keeps a copy. - let name: String = s.str(id, b"Desc").unwrap_or_default().chars().take(MAX_NAME_CHARS).collect(); + let name: String = s.str(id, b"Desc").unwrap_or_default().chars().take(MAX_NAME_CHARS.min(self.budget.name_chars)).collect(); + self.budget.name_chars -= name.chars().count(); let visible = s.bool(id, b"Visi").unwrap_or(true); let locked = s.bool(id, b"Edtb").is_some_and(|e| !e); let opacity = s.f64(id, b"Opac").map_or(1.0, |o| o.clamp(0.0, 1.0)); @@ -312,6 +337,7 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> { self.warn(format!("a {} that could not be read", describe(class))); return Ok(Kind::Unsupported); }; + self.budget.spend(local.subpaths.iter().map(|sp| sp.segments.len() + 1).sum())?; let (fills, strokes) = paint::node_paint(self, id, world); Kind::Shape { path: local.transformed(world), fills, strokes, even_odd } } @@ -645,3 +671,15 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> { Kind::Artboard { rect, path: path.transformed(world), even_odd, background, strokes } } } + +#[cfg(test)] +mod budget_tests { + use super::*; + + #[test] + fn shared_work_is_bounded() { + let mut b = Budget::FULL; + assert!(b.spend(MAX_DOCUMENT_WORK).is_ok()); + assert_eq!(b.spend(1), Err(Error::Limit("too much shared geometry or page data"))); + } +} diff --git a/crates/affinity/src/paint.rs b/crates/affinity/src/paint.rs index 12102581e..c7b2acbbd 100644 --- a/crates/affinity/src/paint.rs +++ b/crates/affinity/src/paint.rs @@ -238,12 +238,13 @@ pub(crate) fn descriptor(r: &mut Reader, id: ObjId, local: Affine) -> Option> = match s.field(grad, b"Posn") { - Some(Value::Array(a)) => a.iter().filter_map(floats).collect(), - _ => Vec::new(), + // Convert only the positions that pair with a colour, as shared attributes repeat this per run. + let posn: &[Value] = match s.field(grad, b"Posn") { + Some(Value::Array(a)) => a, + _ => &[], }; let mut stops = Vec::new(); - for (c, p) in cols.iter().zip(&posn).take(MAX_STOPS) { + for (c, p) in cols.iter().zip(posn.iter().filter_map(floats)).take(MAX_STOPS) { let color = color(r, *c)?; let offset = p.first().copied().filter(|v| v.is_finite()).unwrap_or(0.0).clamp(0.0, 1.0); let bias = p.get(1).copied().filter(|v| v.is_finite()).unwrap_or(0.5).clamp(0.0, 1.0); diff --git a/crates/affinity/src/raster.rs b/crates/affinity/src/raster.rs index d458c08a7..bb8c1c88b 100644 --- a/crates/affinity/src/raster.rs +++ b/crates/affinity/src/raster.rs @@ -195,6 +195,11 @@ fn decode_within(r: &mut Reader, bitmap: ObjId, crop: (u32, u32, u32, u32), worl if let Some(name) = s.entry(bitmap, b"Bckg") { let width = s.int(bitmap, b"BmpW").and_then(|v| u32::try_from(v).ok()).unwrap_or(0); let height = s.int(bitmap, b"BmpH").and_then(|v| u32::try_from(v).ok()).unwrap_or(0); + // The whole source is decoded for every layer that reads it, so charge it all, not only the crop. + let Some(left) = r.budget.pixels.checked_sub(u64::from(width) * u64::from(height)) else { + return Err(Error::Limit("embedded image pixels")); + }; + r.budget.pixels = left; match original(r, name).and_then(|bytes| source_pixels(&bytes, width, height)) { Ok(pixels) => Some((width as usize, pixels)), Err(Error::Limit(e)) => return Err(Error::Limit(e)), @@ -318,8 +323,10 @@ fn grid(s: &stream::Stream, bitmap: ObjId, c: usize, bps: usize) -> (usize, usiz let height = s.int(bitmap, b"BmpH").and_then(|v| usize::try_from(v).ok()).unwrap_or(0); let tw = s.int(bitmap, &tag(b"TWi", c)).and_then(|v| usize::try_from(v).ok()).unwrap_or_else(|| width.saturating_mul(bps).div_ceil(TILE)); let th = s.int(bitmap, &tag(b"THi", c)).and_then(|v| usize::try_from(v).ok()).unwrap_or_else(|| height.div_ceil(TILE)); + // A grid larger than the bitmap has tiles no crop can reach: don't convert or scan them on every reference. + let tiles = width.saturating_mul(bps).div_ceil(TILE).saturating_mul(height.div_ceil(TILE)); let states: Vec = match s.field(bitmap, &tag(b"Sta", c)) { - Some(Value::Array(a)) => a.iter().map(|v| if let Value::UInt(u) = v { u8::try_from(*u).unwrap_or(255) } else { 255 }).collect(), + Some(Value::Array(a)) => a.iter().take(tiles).map(|v| if let Value::UInt(u) = v { u8::try_from(*u).unwrap_or(255) } else { 255 }).collect(), _ => Vec::new(), }; (tw, th, states) diff --git a/crates/affinity/src/text.rs b/crates/affinity/src/text.rs index 1a3b6e544..d2bb55ca0 100644 --- a/crates/affinity/src/text.rs +++ b/crates/affinity/src/text.rs @@ -51,6 +51,9 @@ const MAX_TABLE_LINES: usize = 4096; /// Cells a table may have (rows × columns): each becomes a text object with its edge lines. const MAX_TABLE_CELLS: usize = 1 << 16; +/// Work charged for each copy of a run's formatting a table cell keeps. +const TABLE_RUN_COST: usize = 16; + /// A table node (`TxtT`): the grid, the cells' text and the lines on the cell edges. pub(crate) fn table(r: &mut Reader, id: ObjId, world: Affine) -> Option { let s = r.s; @@ -108,9 +111,10 @@ pub(crate) fn table(r: &mut Reader, id: ObjId, world: Affine) -> Option
{ let mut parts = run.text.split(CELL_BREAK).peekable(); while let Some(part) = parts.next() { if !part.is_empty() { + // Each cell keeps its own copy of the run's formatting, so each copy is charged. match target.and_then(|i| cells.get_mut(i)) { - Some(cell) => cell.runs.push(TextRun { text: part.to_string(), ..run.clone() }), - None => extra = true, + Some(cell) if r.budget.spend(TABLE_RUN_COST).is_ok() => cell.runs.push(TextRun { text: part.to_string(), ..run.clone() }), + _ => extra = true, } } if parts.peek().is_some() { @@ -157,6 +161,13 @@ pub(crate) fn story_runs(r: &mut Reader, story: ObjId, world: Affine) -> (Vec (Vec = Vec::new(); let mut open: Option = None; - for run in s.obj(block, b"PAtt").map(|p| s.objs(p, b"Runs")).unwrap_or_default() { + for run in paragraph_runs { let Some(attrs) = s.obj(run, b"Item") else { continue }; let a = paragraph(r, attrs); first_align.get_or_insert(a.align); @@ -238,7 +249,7 @@ pub(crate) fn story_runs(r: &mut Reader, story: ObjId, world: Affine) -> (Vec