From 95dcbfe59fc5b8d251d90ae0c0844148a0159271 Mon Sep 17 00:00:00 2001 From: Don Omondi <282319807+mockydevs@users.noreply.github.com> Date: Fri, 6 Nov 2026 20:11:52 -0500 Subject: [PATCH 1/2] EPS import: an image's transient data stops counting against the memory budget The sample planes an image is decoded from, and the strings a procedure data source hands back to fill them, are freed once the image is decoded, so they stop counting against the 1 GiB budget then; the pixels kept still count. A file of many images whose procedure returns one large buffer each time no longer runs out of budget. --- crates/eps/src/import/data/ops.rs | 6 ++++++ crates/eps/src/import/interp.rs | 11 +++++++++++ crates/eps/src/import/tests.rs | 14 ++++++++++++++ docs/file-format-parity.md | 3 ++- 4 files changed, 33 insertions(+), 1 deletion(-) diff --git a/crates/eps/src/import/data/ops.rs b/crates/eps/src/import/data/ops.rs index baca4d0ed..9e4cc3a8b 100644 --- a/crates/eps/src/import/data/ops.rs +++ b/crates/eps/src/import/data/ops.rs @@ -657,8 +657,12 @@ impl Interp<'_> { return ps_err("rangecheck", "DataSource"); } let plane = row.checked_mul(s.h).filter(|p| *p <= MAX_DATA).ok_or(PsError::Limit("decoded data is too large"))?; + // The planes and the strings a procedure gives to fill them live only while the image is + // decoded: they stop counting once it is (the pixels made from them still count). + let before = self.allocated(); self.alloc(plane.saturating_mul(sources.len()))?; let mut planes = self.read_sources(sources, row * s.h, if multi { row } else { row * s.h })?; + let transient = self.allocated().saturating_sub(before); for p in &mut planes { p.resize(row * s.h, 0); } @@ -734,6 +738,8 @@ impl Interp<'_> { rgba.extend([rgb[0], rgb[1], rgb[2], if masked { 0 } else { 255 }]); } } + drop(planes); + self.unalloc(transient); Ok(Some(Pixels { w: s.w as u32, h: s.h as u32, rgba, inks, matrix: s.matrix })) } diff --git a/crates/eps/src/import/interp.rs b/crates/eps/src/import/interp.rs index 75f7c7d13..da558f174 100644 --- a/crates/eps/src/import/interp.rs +++ b/crates/eps/src/import/interp.rs @@ -341,6 +341,17 @@ impl<'a> Interp<'a> { Ok(()) } + /// The bytes counted against [`MAX_MEMORY`] so far. + pub(crate) fn allocated(&self) -> usize { + self.allocated + } + + /// Stop counting `bytes` the program no longer holds: buffers that lived only while one + /// operator ran, freed when it returned. + pub(crate) fn unalloc(&mut self, bytes: usize) { + self.allocated = self.allocated.saturating_sub(bytes); + } + fn tick(&mut self) -> Res { self.spend(1) } diff --git a/crates/eps/src/import/tests.rs b/crates/eps/src/import/tests.rs index 86fa8e35d..40cc4edc7 100644 --- a/crates/eps/src/import/tests.rs +++ b/crates/eps/src/import/tests.rs @@ -1381,6 +1381,20 @@ fn the_planes_of_multi_source_images_are_charged() { assert!(r.warnings.iter().any(|w| w.contains("too much memory")), "{:?}", r.warnings); } +/// The planes an image is decoded from, and the strings a procedure gives to fill them, stop +/// counting against the memory budget once the image is decoded: they are freed then. A procedure +/// that hands back one large buffer for every image (1 MiB here, for a one-pixel image) charged the +/// whole buffer each time, and a file of many images came in as its preview. +#[test] +fn an_images_transient_data_is_not_charged_after_it() { + let body = "/buf 1048576 string def\n1100 { 1 1 8 [1 0 0 1 0 0] { buf } image } repeat\n"; + let r = import(&eps(body)).unwrap(); + assert!(!r.warnings.iter().any(|w| w.contains("too much memory")), "{:?}", r.warnings); + let mut images = 0; + r.document.walk(|n| images += usize::from(matches!(n.kind, NodeKind::Image(_)))); + assert_eq!(images, 1100); +} + /// #3568: a single sample plane over the data limit is refused. #[test] fn an_oversized_image_plane_is_refused() { diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index d3e2f0303..038eb8a7b 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (EPS import: an image's transient data stops counting against the memory budget once it is decoded; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x) Every format Illustrator opens, places, saves or exports, against VectorCraft's format table (`FORMATS`, `OPEN_EXTS`, `PLACE_EXTS` in `crates/engine/src/cmd/fileio/formats.rs`). Illustrator's list comes from its public @@ -191,6 +191,7 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri | Date | Change | Summary | |---|---|---| +| 2026-11-06 | minor | EPS import: the buffers an image is decoded from (its sample planes, and the strings a procedure data source hands back to fill them) stop counting against the PostScript reader's 1 GiB memory budget once the image is decoded, as they are freed then; the pixels kept still count. A user's Illustrator 25.2 EPS with 71 CMYK images (used locally, not committed) used to stop with "the program uses too much memory" and come in as its screen preview; it now opens with its art. The budget itself is unchanged. Scores and estimates unchanged. | | 2026-11-06 | minor | SVG import: filter and mask regions clip (SVG 1.1 §15.5, §14.4). An object whose imported blur, shadow or glow reaches past its filter's region comes in inside a clip group cut to the region (#5876), and a mask's art is cut to the mask's region, in user space or the object's bounding box (#5875). Objects that fit their regions, and our own exports, are unchanged; primitive subregions are still ignored. Tests `import_live::a_blur_is_cut_at_its_filter_region`, `import_live::a_mask_counts_only_inside_its_region` (crate `vectorcraft-svg`); scores unchanged | | 2026-11-06 | minor | SVG import: a Gaussian blur keeps the colour space its filter asks for with `color-interpolation-filters` (initially `linearRGB`, SVG 1.1 §15.7.6): a linear-light blur becomes Gaussian Blur with the new `linear` parameter, which blurs the colours in linear light (black beside white meets at about 188/255 instead of 128), and SVG export writes it back on the `feGaussianBlur` (#5877). Blurs that ask for sRGB, and the blurs our own export writes, are as before. CSS export still writes `blur()`. Tests `pixel::tests::linear_light_blur_mixes_light_not_values` (crate `vectorcraft-effects`), `import_live::a_gaussian_blur_keeps_its_colour_space` (crate `vectorcraft-svg`); scores unchanged | | 2026-11-06 | minor | PDF export and PDF-compatible `.ai` saves write each object's URL (Attributes panel) as a link annotation with a URI action (web and mail URLs only) over the bounds of the object that shows, trimmed by its clipping groups (ISO 32000-1, 12.5.6.5 and 12.6.4.7); hidden objects, layers that don't print and prints have none, and PDF/X files leave them out with a warning (#5785). Own-fixture tests in `tests::links`; PDF import doesn't read links back as URLs yet; scores unchanged | From 53f60823b2081f2378e2087bb279081120746b6f Mon Sep 17 00:00:00 2001 From: Don Omondi <282319807+mockydevs@users.noreply.github.com> Date: Sat, 7 Nov 2026 07:10:41 -0500 Subject: [PATCH 2/2] EPS import: release only the image decoder's own buffers from the memory budget What a data source procedure allocates stays counted: only the sample planes, the decoder's copies of the strings a procedure returns, and the split buffers of an interleaved masked image stop counting once the image is decoded. --- crates/eps/src/import/data/ops.rs | 37 ++++++++++++++++++++++--------- crates/eps/src/import/interp.rs | 9 ++------ crates/eps/src/import/tests.rs | 19 ++++++++++++---- docs/file-format-parity.md | 4 ++-- 4 files changed, 46 insertions(+), 23 deletions(-) diff --git a/crates/eps/src/import/data/ops.rs b/crates/eps/src/import/data/ops.rs index 9b758e252..0375de876 100644 --- a/crates/eps/src/import/data/ops.rs +++ b/crates/eps/src/import/data/ops.rs @@ -451,6 +451,10 @@ impl Interp<'_> { continue; } let want = step.min(n - o.len()); + // The bytes of this read's own copy of a procedure's string: counted while it + // lives, released once it is appended (the string itself is the program's, and + // stays counted as long as the program can hold it). + let mut copied = 0; let chunk = match src { Source::File(f) => self.read(f, want)?, // A string gives its bytes over and over. @@ -462,11 +466,14 @@ impl Interp<'_> { self.call(p.clone())?; let s = self.pop_str()?.to_vec(); self.alloc(s.len())?; + copied = s.len(); s } }; *e = chunk.is_empty(); o.extend_from_slice(chunk.get(..chunk.len().min(n - o.len())).unwrap_or_default()); + drop(chunk); + self.unalloc(copied); } } Ok(out) @@ -549,12 +556,16 @@ impl Interp<'_> { } return Ok(None); } + // Interleaved data is read into this decoder's own buffers, split into the two images' + // samples: those stop counting once both are decoded (their pixels still count). + let mut charged = 0; if interleave != 3.0 { let src = image.sources.first().cloned().ok_or(PsError::Ps("undefined", "DataSource".into()))?; let (pixels, samples) = if interleave == 2.0 { let (mrow, drow) = (stencil.row_bytes(1), image.row_bytes(n)); let total = mrow.saturating_mul(stencil.h).saturating_add(drow.saturating_mul(image.h)); self.alloc(total)?; + charged = total; let raw = self.source_bytes(&src, total.min(MAX_DATA))?; by_rows(&raw, (mrow, stencil.h), (drow, image.h)) } else { @@ -562,15 +573,21 @@ impl Interp<'_> { stencil.bpc = image.bpc; (stencil.w, stencil.h) = (image.w, image.h); let row = (image.w * (n + 1) * usize::from(image.bpc)).div_ceil(8); - self.alloc(row.saturating_mul(image.h))?; - let raw = self.source_bytes(&src, row.saturating_mul(image.h).min(MAX_DATA))?; + charged = row.saturating_mul(image.h); + self.alloc(charged)?; + let raw = self.source_bytes(&src, charged.min(MAX_DATA))?; by_samples(&raw, image.w, image.h, n, image.bpc) }; stencil.sources = vec![Source::Str(pixels)]; image.sources = vec![Source::Str(samples)]; } - let Some(mut img) = self.decode_image(image)? else { return Ok(None) }; - let Some(m) = self.decode_image(stencil)? else { return Ok(Some(img)) }; + let decoded = match self.decode_image(image) { + Ok(Some(img)) => self.decode_image(stencil).map(|m| Some((img, m))), + other => other.map(|_| None), + }; + self.unalloc(charged); + let Some((mut img, m)) = decoded? else { return Ok(None) }; + let Some(m) = m else { return Ok(Some(img)) }; let (w, h, mw, mh) = (img.w as usize, img.h as usize, m.w as usize, m.h as usize); for (i, px) in img.rgba.as_chunks_mut::<4>().0.iter_mut().enumerate() { let (x, y) = (i % w, i / w); @@ -689,12 +706,12 @@ impl Interp<'_> { return ps_err("rangecheck", "DataSource"); } let plane = row.checked_mul(s.h).filter(|p| *p <= MAX_DATA).ok_or(PsError::Limit("decoded data is too large"))?; - // The planes and the strings a procedure gives to fill them live only while the image is - // decoded: they stop counting once it is (the pixels made from them still count). - let before = self.allocated(); - self.alloc(plane.saturating_mul(sources.len()))?; + // The sample planes are this decoder's own and live only while the image is decoded: they + // stop counting once it is (the pixels made from them still count). What the data source + // procedures allocate is the program's and stays counted. + let plane_bytes = plane.saturating_mul(sources.len()); + self.alloc(plane_bytes)?; let mut planes = self.read_sources(sources, row * s.h, if multi { row } else { row * s.h })?; - let transient = self.allocated().saturating_sub(before); for p in &mut planes { p.resize(row * s.h, 0); } @@ -771,7 +788,7 @@ impl Interp<'_> { } } drop(planes); - self.unalloc(transient); + self.unalloc(plane_bytes); Ok(Some(Pixels { w: s.w as u32, h: s.h as u32, rgba, inks, matrix: s.matrix })) } diff --git a/crates/eps/src/import/interp.rs b/crates/eps/src/import/interp.rs index 783bf0a1a..9252d827c 100644 --- a/crates/eps/src/import/interp.rs +++ b/crates/eps/src/import/interp.rs @@ -350,13 +350,8 @@ impl<'a> Interp<'a> { Ok(()) } - /// The bytes counted against [`MAX_MEMORY`] so far. - pub(crate) fn allocated(&self) -> usize { - self.allocated - } - - /// Stop counting `bytes` the program no longer holds: buffers that lived only while one - /// operator ran, freed when it returned. + /// Stop counting `bytes` the program no longer holds: buffers an operator made for itself and + /// freed before it returned. Never for what the program's own procedures allocated. pub(crate) fn unalloc(&mut self, bytes: usize) { self.allocated = self.allocated.saturating_sub(bytes); } diff --git a/crates/eps/src/import/tests.rs b/crates/eps/src/import/tests.rs index 7f2f10471..395ee130e 100644 --- a/crates/eps/src/import/tests.rs +++ b/crates/eps/src/import/tests.rs @@ -1419,10 +1419,10 @@ fn the_planes_of_multi_source_images_are_charged() { assert!(r.warnings.iter().any(|w| w.contains("too much memory")), "{:?}", r.warnings); } -/// The planes an image is decoded from, and the strings a procedure gives to fill them, stop -/// counting against the memory budget once the image is decoded: they are freed then. A procedure -/// that hands back one large buffer for every image (1 MiB here, for a one-pixel image) charged the -/// whole buffer each time, and a file of many images came in as its preview. +/// The planes an image is decoded from, and the decoder's copies of the strings a procedure gives +/// to fill them, stop counting against the memory budget once the image is decoded: they are freed +/// then. A procedure that hands back one large buffer for every image (1 MiB here, for a one-pixel +/// image) charged the whole buffer each time, and a file of many images came in as its preview. #[test] fn an_images_transient_data_is_not_charged_after_it() { let body = "/buf 1048576 string def\n1100 { 1 1 8 [1 0 0 1 0 0] { buf } image } repeat\n"; @@ -1433,6 +1433,17 @@ fn an_images_transient_data_is_not_charged_after_it() { assert_eq!(images, 1100); } +/// What a data source procedure allocates stays counted, whatever image it fills: a procedure +/// that keeps each new string (in an array here) still runs into the memory budget, however small +/// the images it fills are. +#[test] +fn strings_a_data_source_keeps_stay_charged() { + let body = "/keep 1100 array def /i 0 def\n\ + 1100 { 1 1 8 [1 0 0 1 0 0] { keep i 1048576 string put keep i get /i i 1 add def } image } repeat\n"; + let r = import(&eps(body)).unwrap(); + assert!(r.warnings.iter().any(|w| w.contains("too much memory")), "{:?}", r.warnings); +} + /// #3568: a single sample plane over the data limit is refused. #[test] fn an_oversized_image_plane_is_refused() { diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 67b12e70c..e971ce731 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (EPS import: an image's transient data stops counting against the memory budget once it is decoded; EPS and `.ai` editing data: a transparency set inside a clipping group stays after its `Q`; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (EPS import: an image decoder's own buffers stop counting against the memory budget once it is decoded; EPS and `.ai` editing data: a transparency set inside a clipping group stays after its `Q`; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x) Every format Illustrator opens, places, saves or exports, against VectorCraft's format table (`FORMATS`, `OPEN_EXTS`, `PLACE_EXTS` in `crates/engine/src/cmd/fileio/formats.rs`). Illustrator's list comes from its public @@ -212,7 +212,7 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri | Date | Change | Summary | |---|---|---| -| 2026-11-06 | minor | EPS import: the buffers an image is decoded from (its sample planes, and the strings a procedure data source hands back to fill them) stop counting against the PostScript reader's 1 GiB memory budget once the image is decoded, as they are freed then; the pixels kept still count. A user's Illustrator 25.2 EPS with 71 CMYK images (used locally, not committed) used to stop with "the program uses too much memory" and come in as its screen preview; it now opens with its art. The budget itself is unchanged. Scores and estimates unchanged. | +| 2026-11-06 | minor | EPS import: the buffers an image decoder makes for itself (its sample planes, its copies of the strings a procedure data source hands back, and the split buffers of an interleaved masked image) stop counting against the PostScript reader's 1 GiB memory budget once the image is decoded, as they are freed then; the pixels kept, and anything the program's own procedures allocate, still count. A user's Illustrator 25.2 EPS with 71 CMYK images (used locally, not committed) used to stop with "the program uses too much memory" and come in as its screen preview; it now opens with its art. The budget itself is unchanged. Scores and estimates unchanged. | | 2026-11-06 | minor | EPS and `.ai` editing data: `Q` restores the painting state but not the transparency (`Xy`), which the writer only sets when the next object's differs from the last one written. A Screen glow followed by a clipping group set back to Normal no longer leaves the type after the group in Screen: two users' Illustrator 26.5 and 30 EPS posters (used locally, not committed) now show their gold heading and crescent, and both open from their layers (2.5% and 2.7% from the page before, under the limit after). Scores and estimates unchanged. | | 2026-11-06 | minor | EPS and `.ai` editing data: a blend whose spine was never edited, written with its keys and no spine part, comes in as a live blend along the straight lines between its keys' centres (in its record's order) instead of as its drawn steps. Checked on six users' Illustrator EPS files (used locally, not committed): every blend in four of them now comes in live, their renders unchanged; two still have blends the reader can't match to their drawn steps. Scores and estimates unchanged. | | 2026-11-06 | minor | EPS and `.ai` editing data: the art styles are found before the art is read, so objects in symbols defined ahead of `%AI9_BeginArtStyles` (as Illustrator 24 writes it) come in as themselves with their style instead of their drawn look. Checked on two users' EPS files (used locally, not committed): one's Symbols panel gains live Feather and Round Corners, its drawn-look count drops from 5 to 2, and both render unchanged; own-fixture test in `import::ai::tests`; scores and hours unchanged |