diff --git a/crates/eps/src/import/data/ops.rs b/crates/eps/src/import/data/ops.rs index 4397a0e23..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,7 +706,11 @@ 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"))?; - 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 })?; for p in &mut planes { p.resize(row * s.h, 0); @@ -766,6 +787,8 @@ impl Interp<'_> { rgba.extend([rgb[0], rgb[1], rgb[2], if masked { 0 } else { 255 }]); } } + drop(planes); + 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 d3d9c2bdd..e0abf1ce5 100644 --- a/crates/eps/src/import/interp.rs +++ b/crates/eps/src/import/interp.rs @@ -367,6 +367,12 @@ impl<'a> Interp<'a> { Ok(()) } + /// 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); + } + 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 70e69dae1..18db3e965 100644 --- a/crates/eps/src/import/tests.rs +++ b/crates/eps/src/import/tests.rs @@ -1436,6 +1436,31 @@ 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 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"; + 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); +} + +/// 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 e269f1a68..a28d33675 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-07 · **Change:** minor (EPS import: sampled functions interpolate between their samples; `.ai` and EPS type from the text document keeps its tab stops; it keeps auto leading and hyphenation options, and may show more of the file's last lines in an area frame; `.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; EPS import: gradients drawn as abutting strips come in as one gradient, without seams; EPS import: composite fonts draw their Type 42 glyphs; 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-07 · **Change:** minor (EPS import: an image decoder's own buffers stop counting against the memory budget once it is decoded; EPS import: sampled functions interpolate between their samples; `.ai` and EPS type from the text document keeps its tab stops; it keeps auto leading and hyphenation options, and may show more of the file's last lines in an area frame; `.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; EPS import: gradients drawn as abutting strips come in as one gradient, without seams; EPS import: composite fonts draw their Type 42 glyphs; 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 @@ -320,6 +320,7 @@ Names in the editing data that aren't UTF-8 (layers, objects, artboards, colors, | Date | Change | Summary | |---|---|---| +| 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-07 | minor | EPS import: a sampled function (type 0) of two or more inputs took the nearest sample, so a function-based shading (type 1) over one came in as flat blocks; it is now interpolated between its samples (multilinearly), and with `Order 3` by cubics through them (Catmull-Rom, kept in the function's range), as the PostScript Language Reference (3rd ed., §3.10.1, type 0 functions) defines. An axial or radial shading whose function is a linearly interpolated sampled function of one input has a stop at each sample, not 33 evenly spaced ones. Tests `sampled_functions_interpolate_linearly_or_by_cubics`, `mesh_and_function_shadings_become_gradient_meshes`; a 2 × 2 function-based shading is 1.2% from Ghostscript's render (it was 45.9%); scores unchanged | | 2026-11-07 | minor | EPS and `.ai` type from the text document keeps its paragraphs' tab stops (positions and alignments; leaders aren't read), at most 1,024 a paragraph and 2^20 a story, past which the default stops apply with a warning. A user's `.ai` (used locally, not committed) whose price lists came in on the default stops now lays them out as its page has them (its type 0.8% from its page, from 1.5%; what differs is hyphenation in Hungarian and type in a font that isn't installed). Scores and estimates unchanged. | | 2026-11-07 | minor | EPS and `.ai` type from the text document: auto leading stays auto at the paragraph's share (it was the first two lines' distance for every line, so 10 pt lines under a 15 pt heading came in 18 pt apart), the paragraph's hyphenation options are read, and area type of one frame whose last lines the file sets in too short a frame for them here may get a frame taller by its lines' deepest descent plus 0.05 pt, when that shows more of them. A user's `.ai` (used locally, not committed) whose type differed from its page by 6.6%, so that it opened from its PDF part, now opens from its layers (4.9%; type with tab stops in right-aligned paragraphs still differs, a separate issue) (#6137). Scores and estimates unchanged. |