diff --git a/crates/eps/src/scene.rs b/crates/eps/src/scene.rs index e8ffa4b278..0ec561dd31 100644 --- a/crates/eps/src/scene.rs +++ b/crates/eps/src/scene.rs @@ -55,8 +55,99 @@ const MAX_DEVICE_N: usize = 32; const MISSING_PATTERN: &str = "missing patterns are written as mid-grey"; const TOO_LARGE: &str = "the artwork is too large for EPS: what follows 256 MB of PostScript was left out"; /// The most PostScript the page body may grow to: symbols and patterns that hold one another -/// expand into every instance, so nesting depth alone doesn't bound the output. +/// expand into every instance, so nesting depth alone doesn't bound the output. An image placed +/// more than once counts its data once. const MAX_BODY: usize = 256 << 20; +/// Most compressed bytes in one string of an image's samples at Level 2 (under PostScript's +/// 65,535-byte string limit). +const IMAGE_CHUNK: usize = 48 << 10; +/// The start of the procedure that reads an image's data from the lines after it. +const INLINE_SOURCE: &str = "{ /VCsrc currentfile /ASCII85Decode filter def\n"; + +/// An image as written: what sets up its color, its image dictionary and its compressed samples. +/// Placed once, it is written where it is drawn, its data following the operators that read it. +/// Placed more than once, its samples are written once in the setup as `VCim` (a reusable +/// stream at Level 3, an array of strings at Level 2) and each placement reads them from there. +struct ImageCode { + /// The color space or ink operators, each line ending in a line break. + prelude: String, + /// `<< /ImageType … /ImageMatrix […]` with its mask colors: the dictionary up to its data source. + dict: String, + /// The decoding filter of the samples: `/FlateDecode` or `/RunLengthDecode`. + filter: &'static str, + /// `image` or `imagemask`. + op: &'static str, + /// The compressed samples in ASCII85, as lines with the end marker. + ascii: String, + /// The compressed samples, kept at Level 2, where a reused image's samples are written as + /// strings of at most [`IMAGE_CHUNK`] bytes. + data: Vec, + /// How many placements in the page draw it. + uses: usize, + /// The length of [`ImageCode::push_inline`]'s text. + inline_len: usize, + /// The length of [`ImageCode::reference`]'s text. + ref_len: usize, +} + +impl ImageCode { + /// The operators and data that draw the image where it is placed. + fn push_inline(&self, s: &mut String) { + s.push_str(&self.prelude); + s.push_str(INLINE_SOURCE); + s.push_str(&self.dict); + s.push_str(&self.inline_read()); + s.push_str(&self.ascii); + } + + /// The end of the procedure [`ImageCode::push_inline`] writes. The procedure is read whole + /// before it runs: the image data follows it, and flushing the ASCII85 filter afterwards reads + /// past its end marker. + fn inline_read(&self) -> String { + format!("\n/DataSource VCsrc {} filter >> {}\nVCsrc flushfile }} exec\n", self.filter, self.op) + } + + /// The operators that draw the image `VCim` holds, for an image placed more than once. + fn reference(&self, i: usize, level: Level) -> String { + match level { + // The stream is read again from its start. + Level::Three => { + format!("{}VCim{i} resetfile\n{}\n/DataSource VCim{i} {} filter >> {}\n", self.prelude, self.dict, self.filter, self.op) + } + // A procedure gives the strings in turn, then an empty string: the end of the data. + Level::Two => format!( + "{}/VCi 0 def\n{}\n/DataSource {{VCi VCim{i} length lt {{VCim{i} VCi get /VCi VCi 1 add def}} {{()}} ifelse}} {} filter >> {}\n", + self.prelude, self.dict, self.filter, self.op + ), + } + } + + /// The setup's definition of `VCim`: the image's compressed samples, once. + fn resource(&self, i: usize, level: Level) -> String { + let mut s = String::with_capacity(self.ascii.len() + 200); + match level { + Level::Three => { + // The stream reads all of its data when it is made, so the data follows the + // procedure that makes it. + let intent = if self.op == "imagemask" { 1 } else { 0 }; + let _ = writeln!( + s, + "{{ /VCsrc currentfile /ASCII85Decode filter def\n/VCim{i} VCsrc << /Intent {intent} /AsyncRead false /CloseSource false >> /ReusableStreamDecode filter def\nVCsrc flushfile }} exec" + ); + s.push_str(&self.ascii); + } + Level::Two => { + let _ = writeln!(s, "/VCim{i} ["); + for chunk in self.data.chunks(IMAGE_CHUNK) { + s.push_str("<~"); + s.push_str(&ps::ascii85(chunk)); + } + s.push_str("] def\n"); + } + } + s + } +} /// What the walk wrote: definitions for the setup, the page, the spot colours it uses (name and /// CMYK equivalent) and the warnings. @@ -157,10 +248,18 @@ pub(crate) struct Scene<'a> { device_n: Vec, warnings: Vec, brushes: Option>, - /// Image keys → what draws them (`None`: left out). - images: HashMap>>, + /// Image keys → the index in `codes` of what draws them (`None`: left out). + images: HashMap>, /// The source key and solid paint together identify a colorized encoding. - colorized_images: HashMap<(String, String), Option>>, + colorized_images: HashMap<(String, String), Option>, + /// The encoded images. + codes: Vec, + /// Where in `body` each placement of an encoded image goes (in order) and the index of its code. + placements: Vec<(usize, usize)>, + /// What the placements in `placements` add to `body`'s length when the page is finished. + pending: usize, + /// The most the page body may grow to ([`MAX_BODY`]). + max_body: usize, /// Symbols and brush art being written inside one another. nest: u32, } @@ -181,10 +280,21 @@ impl<'a> Scene<'a> { brushes: None, images: HashMap::new(), colorized_images: HashMap::new(), + codes: vec![], + placements: vec![], + pending: 0, + max_body: MAX_BODY, nest: 0, } } + /// This scene with a page body of at most `max` bytes. + #[cfg(test)] + pub fn with_max_body(mut self, max: usize) -> Self { + self.max_body = max; + self + } + pub fn run(mut self) -> Page { let doc = self.doc; for l in &doc.layers { @@ -199,7 +309,27 @@ impl<'a> Scene<'a> { self.page() } - fn page(self) -> Page { + fn page(mut self) -> Page { + if !self.placements.is_empty() { + let level = self.o.level; + let mut body = String::with_capacity(self.body.len().saturating_add(self.pending)); + let mut at = 0; + for &(offset, i) in &self.placements { + body.push_str(self.body.get(at..offset).unwrap_or_default()); + at = offset; + let Some(code) = self.codes.get(i) else { continue }; + if code.uses > 1 { + body.push_str(&code.reference(i, level)); + } else { + code.push_inline(&mut body); + } + } + body.push_str(self.body.get(at..).unwrap_or_default()); + self.body = body; + for (i, code) in self.codes.iter().enumerate().filter(|(_, c)| c.uses > 1) { + self.setup.push_str(&code.resource(i, level)); + } + } let custom = self .spots .iter() @@ -337,7 +467,7 @@ impl<'a> Scene<'a> { if let NodeKind::Layer { template: true, .. } = n.kind { return; } - if self.body.len() >= MAX_BODY { + if self.size() >= self.max_body { self.warn(TOO_LARGE); return; } @@ -347,7 +477,7 @@ impl<'a> Scene<'a> { _ => {} } self.note_losses(n); - let was = self.body.len(); + let (was, placed) = (self.body.len(), self.placements.len()); match &n.kind { NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } => { children.iter().for_each(|c| self.node(c, false)); @@ -402,12 +532,29 @@ impl<'a> Scene<'a> { // Ruler guides draw on screen only. NodeKind::RulerGuide { .. } => {} } - if self.body.len() > MAX_BODY { + if self.size() > self.max_body { self.body.truncate(was); + self.unplace(placed); self.warn(TOO_LARGE); } } + /// How long the page body will be: what is written and what its image placements add. + fn size(&self) -> usize { + self.body.len().saturating_add(self.pending) + } + + /// Take back the image placements after the first `n`, last first. + fn unplace(&mut self, n: usize) { + while self.placements.len() > n { + let Some((_, i)) = self.placements.pop() else { break }; + let Some(code) = self.codes.get_mut(i) else { continue }; + code.uses = code.uses.saturating_sub(1); + let cost = if code.uses == 0 { code.inline_len } else { code.ref_len }; + self.pending = self.pending.saturating_sub(cost); + } + } + /// A clipping group: the art inside its first child's outline. The clipping path's fills /// paint behind the art (clipped) and its strokes over it (not clipped). fn clip_group(&mut self, children: &[Arc]) { @@ -1229,7 +1376,7 @@ impl<'a> Scene<'a> { }; let place = Affine::translate(area.origin().to_vec2()) * Affine::scale_non_uniform(area.width(), area.height()); if let Some(image) = image { - self.place_image(&image, place); + self.place_inline(&image, place); } } @@ -1241,18 +1388,29 @@ impl<'a> Scene<'a> { return; } let encoded = match self.images.get(key) { - Some(e) => e.clone(), + Some(e) => *e, None => { - let e = self.decode_image(blob).and_then(|img| self.encode_image(img.as_raw(), img.width(), img.height())).map(Arc::new); - self.images.insert(key.to_string(), e.clone()); + let e = self.decode_image(blob).and_then(|img| self.encode_image(img.as_raw(), img.width(), img.height())).map(|c| self.add_code(c)); + self.images.insert(key.to_string(), e); e } }; - if let Some(e) = encoded { - self.place_image(&e, place); + if let Some(i) = encoded { + self.place_image(i, place); } } + /// Keep `code` for the placements that draw it: its index in `codes`. + fn add_code(&mut self, mut code: ImageCode) -> usize { + let i = self.codes.len(); + if self.o.level == Level::Three { + code.data = vec![]; + } + code.ref_len = code.reference(i, self.o.level).len(); + self.codes.push(code); + i + } + /// Gray coverage in the selected process or Separation ink. Binary coverage is a stencil; /// continuous coverage leaves zero pixels untouched at Level 3 and writes them as paper at Level 2. fn colorized_image(&mut self, key: &str, blob: Option<&ImageBlob>, width: u32, height: u32, xf: Affine, paint: &Paint) { @@ -1262,19 +1420,19 @@ impl<'a> Scene<'a> { } let cache = (key.to_string(), format!("{paint:?}")); let encoded = match self.colorized_images.get(&cache) { - Some(e) => e.clone(), + Some(e) => *e, None => { - let e = self.encode_colorized(blob, paint).map(Arc::new); - self.colorized_images.insert(cache, e.clone()); + let e = self.encode_colorized(blob, paint).map(|c| self.add_code(c)); + self.colorized_images.insert(cache, e); e } }; - if let Some(e) = encoded { - self.place_image(&e, place); + if let Some(i) = encoded { + self.place_image(i, place); } } - fn encode_colorized(&mut self, blob: Option<&ImageBlob>, paint: &Paint) -> Option { + fn encode_colorized(&mut self, blob: Option<&ImageBlob>, paint: &Paint) -> Option { let Some((w, h, coverage)) = blob.and_then(ImageBlob::coverage) else { self.warn(UNDECODED); return None; @@ -1355,17 +1513,17 @@ impl<'a> Scene<'a> { ("/RunLengthDecode", rl) } }; - let mut out = String::new(); + let mut prelude = String::new(); if binary { // imagemask takes its current ink from the graphics state. - out.push_str(&self.ink_code(ink)); + prelude.push_str(&self.ink_code(ink)); } else { match ink { Ink::Spot { space, .. } => { - let _ = writeln!(out, "VCsp{space} setcolorspace"); + let _ = writeln!(prelude, "VCsp{space} setcolorspace"); } Ink::Process(space, ..) => { - let _ = writeln!(out, "{} setcolorspace", space.name()); + let _ = writeln!(prelude, "{} setcolorspace", space.name()); } } } @@ -1374,19 +1532,16 @@ impl<'a> Scene<'a> { Ink::Process(space, ..) => space.n(), }; let decode = if binary { "1 0".to_string() } else { "0 1 ".repeat(n).trim_end().to_string() }; - let _ = write!( - out, - "{{ /VCsrc currentfile /ASCII85Decode filter def\n<< /ImageType {} /Width {w} /Height {h} /BitsPerComponent {} /Decode [{decode}] /ImageMatrix [{w} 0 0 {h} 0 0]", + let mut dict = format!( + "<< /ImageType {} /Width {w} /Height {h} /BitsPerComponent {} /Decode [{decode}] /ImageMatrix [{w} 0 0 {h} 0 0]", if keyed { 4 } else { 1 }, if binary { 1 } else { 8 }, ); if keyed { let mask: Vec = key.iter().take(n).map(|v| format!("{v} {v}")).collect(); - let _ = write!(out, " /MaskColor [{}]", mask.join(" ")); + let _ = write!(dict, " /MaskColor [{}]", mask.join(" ")); } - let _ = writeln!(out, "\n/DataSource VCsrc {filter} filter >> {}\nVCsrc flushfile }} exec", if binary { "imagemask" } else { "image" }); - out.push_str(&ps::ascii85(&encoded)); - Some(out) + Some(image_code(prelude, dict, filter, if binary { "imagemask" } else { "image" }, encoded)) } /// Ink-setting operators in the cached stencil body, built with the same path as solid fills. @@ -1409,18 +1564,40 @@ impl<'a> Scene<'a> { Some(img.to_rgba8()) } - /// Draw an image of [`Self::encode_image`] on the unit square mapped by `place`. - fn place_image(&mut self, encoded: &str, place: Affine) { + /// Draw the image of `codes[i]` on the unit square mapped by `place`. Its operators go in when + /// the page is finished ([`Self::page`]), once it is known how many placements draw it. + fn place_image(&mut self, i: usize, place: Affine) { if !invertible(place) { return; } - if self.body.len().saturating_add(encoded.len()).saturating_add(64) > MAX_BODY { + let Some(code) = self.codes.get(i) else { return }; + let cost = if code.uses == 0 { code.inline_len } else { code.ref_len }; + if self.size().saturating_add(cost).saturating_add(64) > self.max_body { self.warn(TOO_LARGE); return; } self.line("q"); let _ = writeln!(self.body, "{} cm", ps::matrix(place)); - self.body.push_str(encoded); + self.placements.push((self.body.len(), i)); + self.pending = self.pending.saturating_add(cost); + if let Some(code) = self.codes.get_mut(i) { + code.uses += 1; + } + self.line("Q"); + } + + /// Draw `code`, an image drawn nowhere else, on the unit square mapped by `place`. + fn place_inline(&mut self, code: &ImageCode, place: Affine) { + if !invertible(place) { + return; + } + if self.size().saturating_add(code.inline_len).saturating_add(64) > self.max_body { + self.warn(TOO_LARGE); + return; + } + self.line("q"); + let _ = writeln!(self.body, "{} cm", ps::matrix(place)); + code.push_inline(&mut self.body); self.line("Q"); } @@ -1428,7 +1605,7 @@ impl<'a> Scene<'a> { /// them on the unit square: in the process colour space, transparent pixels masked out at /// Level 3 (a key colour no opaque pixel has), white at Level 2; partly transparent ones over /// white. `None` for an empty image. - fn encode_image(&mut self, rgba: &[u8], w: u32, h: u32) -> Option { + fn encode_image(&mut self, rgba: &[u8], w: u32, h: u32) -> Option { let space = if self.cmyk { Space::Cmyk } else { Space::Rgb }; let mut cache: HashMap<[u8; 3], [u8; 4]> = HashMap::new(); let cms = self.cms.clone(); @@ -1447,7 +1624,7 @@ impl<'a> Scene<'a> { /// Straight CMYK pixels with opacity (five bytes a pixel, `w` × `h`, rows from the top), drawn /// as [`Self::encode_image`] draws its pixels and keeping their ink amounts: partly /// transparent pixels lay down that share of each ink. - fn encode_inks(&mut self, cmyka: &[u8], w: u32, h: u32) -> Option { + fn encode_inks(&mut self, cmyka: &[u8], w: u32, h: u32) -> Option { self.encode_samples(cmyka.as_chunks::<5>().0, w, h, Space::Cmyk, |p: &[u8; 5], al| { [0, 1, 2, 3].map(|i| ((u32::from(p[i]) * al + 127) / 255) as u8) }) @@ -1463,7 +1640,7 @@ impl<'a> Scene<'a> { h: u32, space: Space, mut sample: impl FnMut(&[u8; N], u32) -> [u8; 4], - ) -> Option { + ) -> Option { if w == 0 || h == 0 { return None; } @@ -1503,27 +1680,29 @@ impl<'a> Scene<'a> { ("/RunLengthDecode", rl) } }; - let mut out = String::with_capacity(encoded.len() * 5 / 4 + 400); - let _ = writeln!(out, "{} setcolorspace", space.name()); + let prelude = format!("{} setcolorspace\n", space.name()); let decode = "0 1 ".repeat(n); - let _ = write!( - out, - "{{ /VCsrc currentfile /ASCII85Decode filter def\n<< /ImageType {} /Width {w} /Height {h} /BitsPerComponent 8 /Decode [{}] /ImageMatrix [{w} 0 0 {h} 0 0]", + let mut dict = format!( + "<< /ImageType {} /Width {w} /Height {h} /BitsPerComponent 8 /Decode [{}] /ImageMatrix [{w} 0 0 {h} 0 0]", if keyed { 4 } else { 1 }, decode.trim_end() ); if keyed { let mask: Vec = key.iter().take(n).map(|v| format!("{v} {v}")).collect(); - let _ = write!(out, " /MaskColor [{}]", mask.join(" ")); + let _ = write!(dict, " /MaskColor [{}]", mask.join(" ")); } - // The procedure is read whole before it runs: the image data follows it, and flushing the - // ASCII85 filter afterwards reads past its end marker. - let _ = writeln!(out, "\n/DataSource VCsrc {filter} filter >> image\nVCsrc flushfile }} exec"); - out.push_str(&ps::ascii85(&encoded)); - Some(out) + Some(image_code(prelude, dict, filter, "image", encoded)) } } +/// An image of `prelude`, `dict`, `filter` and `op` (see [`ImageCode`]) with compressed samples +/// `data`, not yet placed. +fn image_code(prelude: String, dict: String, filter: &'static str, op: &'static str, data: Vec) -> ImageCode { + let mut code = ImageCode { prelude, dict, filter, op, ascii: ps::ascii85(&data), data, uses: 0, inline_len: 0, ref_len: 0 }; + code.inline_len = code.prelude.len() + INLINE_SOURCE.len() + code.dict.len() + code.inline_read().len() + code.ascii.len(); + code +} + /// `a` to `b` at `t`. fn mix(a: &[f32], b: &[f32], t: f32) -> Vec { a.iter().zip(b).map(|(x, y)| x + (y - x) * t).collect() diff --git a/crates/eps/src/tests.rs b/crates/eps/src/tests.rs index 13bdbb80e2..0d5649ead5 100644 --- a/crates/eps/src/tests.rs +++ b/crates/eps/src/tests.rs @@ -82,6 +82,17 @@ fn checked_page(ps: &str) -> String { } } '{' | '[' => stack.push(c), + // An ASCII85 string: its digits are data. + '<' if chars.peek() == Some(&'~') => { + let mut last = ' '; + loop { + let c = chars.next().expect("ASCII85 string closes"); + if last == '~' && c == '>' { + break; + } + last = c; + } + } '<' if chars.peek() == Some(&'<') => { chars.next(); stack.push('<'); @@ -679,15 +690,20 @@ fn png_doc() -> Document { for (x, _, p) in img.enumerate_pixels_mut() { *p = if x < 2 { image::Rgba([0, 200, 0, 255]) } else { image::Rgba([0, 0, 0, 0]) }; } + image_doc(&img, 10.0) +} + +/// A 200 × 200 pt document holding `img` as a PNG, its pixels `scale` points wide, at (10, 10). +fn image_doc(img: &image::RgbaImage, scale: f64) -> Document { let mut png = vec![]; img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); let mut d = Document::new(200.0, 200.0); d.images.insert("pic".into(), ImageBlob::new("image/png", png)); let im = ImageObject { key: "pic".into(), - width: 4, - height: 2, - xf: Affine::translate((10.0, 10.0)) * Affine::scale(10.0), + width: img.width(), + height: img.height(), + xf: Affine::translate((10.0, 10.0)) * Affine::scale(scale), link: None, placement: Default::default(), pixels: None, @@ -988,6 +1004,219 @@ fn uncolorized_image_stays_on_original_eps_path() { assert_eq!(page_of(&color, &opts(&color)), original); } +/// A copy of the first layer's object `i`, moved by `xf`, added on top. +fn place_again(d: &mut Document, i: usize, xf: Affine) { + let mut n = (*d.layers[0].children().unwrap()[i]).clone(); + n.id = d.alloc_id(); + if let NodeKind::Image(im) = &mut n.kind { + im.xf = xf * im.xf; + } + d.insert(d.default_layer(), usize::MAX, n).unwrap(); +} + +/// Pixels of noise, opaque. +fn noise(w: u32, h: u32) -> image::RgbaImage { + let mut seed = 1u32; + image::RgbaImage::from_fn(w, h, |_, _| { + let mut px = [255u8; 4]; + for v in &mut px[..3] { + seed = seed.wrapping_mul(1_103_515_245).wrapping_add(12_345); + *v = (seed >> 16) as u8; + } + image::Rgba(px) + }) +} + +/// The pixels of each image an EPS file's page draws, read back from the page. +fn imported_images(bytes: &[u8]) -> Vec> { + fn walk(n: &Node, d: &Document, out: &mut Vec>) { + if let NodeKind::Image(im) = &n.kind { + out.push(image::load_from_memory(&d.images[&im.key].bytes).unwrap().to_rgba8().into_raw()); + } + for c in n.children().into_iter().flatten() { + walk(c, d, out); + } + } + let r = import_with(bytes, false).unwrap(); + assert!(!r.preview, "{:?}", r.warnings); + let mut out = vec![]; + for l in &r.document.layers { + walk(l, &r.document, &mut out); + } + out +} + +/// The samples of an image placed more than once, from the setup that defines them: inflated at +/// Level 3, each string run-length decoded in turn at Level 2. +fn reused_samples(ps: &str, level: Level) -> Vec { + let setup = &ps[ps.find("%%BeginSetup").unwrap()..ps.find("%%EndSetup").unwrap()]; + match level { + Level::Three => ps::inflate(&image_data(setup), 1 << 28).unwrap(), + Level::Two => { + let mut data = vec![]; + for s in setup.split("<~").skip(1) { + let chunk = ps::ascii85_decode(&s[..s.find("~>").unwrap() + 2]).unwrap(); + assert!(chunk.len() <= 65_535, "a string holds at most 65,535 bytes: {}", chunk.len()); + data.extend(chunk); + } + unpack(&data) + } + } +} + +/// The samples of a single image written where it is drawn. +fn inline_samples(ps: &str, level: Level) -> Vec { + match level { + Level::Three => ps::inflate(&image_data(ps), 1 << 28).unwrap(), + Level::Two => unpack(&image_data(ps)), + } +} + +/// How many placements draw the image the setup defines as `VCim`. +fn reuses(ps: &str, i: usize, level: Level) -> usize { + match level { + Level::Three => ps.matches(&format!("VCim{i} resetfile\n")).count(), + Level::Two => ps.matches(&format!("{{VCi VCim{i} length lt")).count(), + } +} + +#[test] +fn an_image_placed_twice_writes_its_samples_once_at_both_levels() { + let single = png_doc(); + let mut d = single.clone(); + place_again(&mut d, 0, Affine::translate((60.0, 80.0)) * Affine::rotate(0.5)); + for level in Level::ALL { + let one = export(&single, &EpsOptions { level, ..opts(&single) }, None, None).unwrap(); + let one_ps = text(&one); + assert!(!one_ps.contains("VCim"), "an image placed once is written where it is drawn: {one_ps}"); + let out = export(&d, &EpsOptions { level, ..opts(&d) }, None, None).unwrap(); + let ps = text(&out); + checked_page(&ps); + assert_eq!(reuses(&ps, 0, level), 2, "{ps}"); + assert!(!ps.contains("VCim1"), "{ps}"); + let body = &ps[ps.find("%%EndSetup").unwrap()..]; + assert!(!body.contains("currentfile") && !body.contains("~>"), "no samples in the page: {ps}"); + match level { + Level::Three => { + assert_eq!(ps.matches("/ReusableStreamDecode filter def").count(), 1, "{ps}"); + assert!(ps.contains("/Intent 0 /AsyncRead false /CloseSource false"), "{ps}"); + assert!(body.contains("/DataSource VCim0 /FlateDecode filter >> image\n"), "{ps}"); + } + Level::Two => { + assert!(ps.contains("/VCim0 [\n<~") && !ps.contains("ReusableStreamDecode"), "{ps}"); + assert!(body.contains("/VCi 0 def\n") && body.contains("/RunLengthDecode filter >> image\n"), "{ps}"); + } + } + assert_eq!(reused_samples(&ps, level), inline_samples(&one_ps, level)); + assert_eq!(out.warnings, one.warnings); + // Read back, each placement draws the image the single placement draws. + let once = imported_images(&one.bytes); + assert_eq!(once.len(), 1); + assert_eq!(imported_images(&out.bytes), vec![once[0].clone(); 2], "{level:?}"); + } +} + +/// An image in an envelope is drawn as clipped pieces, all from one copy of its samples, which the +/// image placed beside the envelope also draws. +#[test] +fn an_envelopes_image_pieces_draw_one_copy_of_its_samples() { + let mut d = png_doc(); + let mut content = (*d.layers[0].children().unwrap()[0]).clone(); + content.id = d.alloc_id(); + let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 50.0, h: 0.0, v: 0.0, horizontal: true, version: 0 }; + let envelope = Node::new( + d.alloc_id(), + NodeKind::Envelope { + content: vec![Arc::new(content)], + kind: Box::new(kind), + fidelity: 50.0, + editing: false, + options: Default::default(), + frame: Box::new(Affine::IDENTITY), + }, + ); + d.insert(d.default_layer(), usize::MAX, envelope).unwrap(); + for level in Level::ALL { + let out = export(&d, &EpsOptions { level, ..opts(&d) }, None, None).unwrap(); + let ps = text(&out); + checked_page(&ps); + // The plain image and 200 pieces: 10 × 10 cells of two triangles. + assert_eq!(reuses(&ps, 0, level), 201, "{ps}"); + assert!(!ps.contains("VCim1"), "{ps}"); + assert_eq!(ps.matches(if level == Level::Three { "/ReusableStreamDecode" } else { "<~" }).count(), 1, "{ps}"); + assert!(!out.warnings.iter().any(|w| w.contains("too large")), "{:?}", out.warnings); + } +} + +#[test] +fn each_ink_of_a_colorized_image_placed_twice_is_written_once() { + let first = Paint::Solid { color: Color::rgb8(255, 0, 0), swatch: Some("Ink".into()), tint: 1.0 }; + let mut d = colorized_doc(vec![0, 255, 0], Some(first)); + let mut n = (*d.layers[0].children().unwrap()[0]).clone(); + n.id = d.alloc_id(); + n.extras_mut().colorize = Some(Box::new(Paint::Solid { color: Color::rgb8(255, 128, 128), swatch: Some("Ink".into()), tint: 0.5 })); + d.insert(d.default_layer(), usize::MAX, n).unwrap(); + place_again(&mut d, 0, Affine::translate((0.0, 50.0))); + place_again(&mut d, 1, Affine::translate((0.0, 100.0))); + for level in Level::ALL { + let ps = page_of(&d, &EpsOptions { level, ..opts(&d) }); + assert_eq!((reuses(&ps, 0, level), reuses(&ps, 1, level)), (2, 2), "{ps}"); + assert_eq!(ps.matches("VCsp0 setcolorspace 1 setcolor\n").count(), 2, "{ps}"); + assert_eq!(ps.matches("VCsp0 setcolorspace 0.5 setcolor\n").count(), 2, "{ps}"); + assert_eq!(ps.matches("imagemask").count(), 4, "{ps}"); + if level == Level::Three { + assert_eq!(ps.matches("/Intent 1 ").count(), 2, "image masks: {ps}"); + } + } +} + +/// Samples longer than PostScript's longest string: one stream at Level 3, several strings at +/// Level 2, each placement reading all of them. +#[test] +fn samples_longer_than_a_string_are_reused_at_both_levels() { + let single = image_doc(&noise(160, 160), 1.0); + let mut d = single.clone(); + place_again(&mut d, 0, Affine::translate((20.0, 0.0))); + for level in Level::ALL { + let one = export(&single, &EpsOptions { level, ..opts(&single) }, None, None).unwrap(); + let out = export(&d, &EpsOptions { level, ..opts(&d) }, None, None).unwrap(); + let ps = text(&out); + checked_page(&ps); + assert_eq!(reuses(&ps, 0, level), 2); + if level == Level::Two { + assert_eq!(ps.matches("<~").count(), 2, "160 × 160 × 3 run-length bytes take two strings"); + } + let samples = reused_samples(&ps, level); + assert_eq!(samples.len(), 160 * 160 * 3); + assert_eq!(samples, inline_samples(&text(&one), level)); + let once = imported_images(&one.bytes); + assert_eq!(imported_images(&out.bytes), vec![once[0].clone(); 2], "{level:?}"); + } +} + +/// The limit on the page body counts the samples of an image placed many times once. Art taken +/// back for passing the limit takes its image placements with it. +#[test] +fn the_page_limit_counts_a_repeated_images_samples_once() { + let single = image_doc(&noise(40, 40), 1.0); + let o = opts(&single); + let inline = crate::scene::Scene::new(&single, &o).run().body.len(); + let mut d = single.clone(); + for k in 1..20 { + place_again(&mut d, 0, Affine::translate((f64::from(k), 0.0))); + } + let page = crate::scene::Scene::new(&d, &o).with_max_body(inline * 3).run(); + assert!(page.warnings.is_empty(), "{:?}", page.warnings); + assert_eq!(reuses(&page.body, 0, Level::Three), 20); + assert!(page.setup.contains("/VCim0 VCsrc")); + // Past the limit, the placements that don't fit are left out with a warning. + let page = crate::scene::Scene::new(&d, &o).with_max_body(inline + 1000).run(); + assert!(page.warnings.iter().any(|w| w.contains("too large")), "{:?}", page.warnings); + let placed = reuses(&page.body, 0, Level::Three); + assert!((2..20).contains(&placed), "{placed}"); + assert!(page.body.len() + page.setup.len() <= inline + 1000 + 400, "{}", page.body.len()); +} + #[test] fn run_length_and_ascii85_round_trip() { let cycle: Vec = (0..=255u8).cycle().take(1000).collect(); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index f04ee3c910..da47a31387 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -610,6 +610,7 @@ since 2026-10-01. - **Placed gradients follow the Warp effects:** under each of the fifteen Warp styles with a bend or a distortion, a gradient placed with the Gradient tool moves and scales from the object's bounds before the warp to its bounds after it, as under Free Distort; a stroke's gradient uses those bounds inflated by the stroke. This holds for an object's own Warp and for a fill's or stroke's own, on the canvas and in Object › Expand of the fill. In a group with a Warp effect, each path's and compound path's gradients follow its own bounds, a nested group's own fills and strokes follow the nested group's bounds, and the group's own fills and strokes follow the group's bounds. In the art Pathfinder and compound shapes take, a symbol instance's Warp moves the gradients of its symbol's art by the art's bounds, and a compound shape's Warp moves its gradients by the bounds of its area. A gradient fitted to the object still fits the warped outline, and a warp with no bend and no distortion leaves the gradient exactly in place. Existing documents with a Warp effect on art with a placed gradient draw differently: before, the gradient stayed where it was while the outline changed. How gradients follow the warp is VectorCraft's own rule; what the Warp effects do to the object is described in [Reshaping with effects](https://helpx.adobe.com/illustrator/using/reshaping-objects-effects.html) (effects `tests::warp_bends_when_the_warp_moves_the_outline`, `bake::tests::baked_warps_move_placed_gradients_from_the_bounds_to_the_warped_bounds`, `bake::tests::a_warp_with_no_bend_leaves_a_placed_gradient_exactly_in_place`, `bake::tests::a_fills_and_a_strokes_own_warp_move_its_placed_gradient_with_the_bounds`, `group::tests::a_groups_warp_moves_the_gradients_placed_on_its_paths_by_each_paths_bounds`, `group::tests::a_groups_warp_moves_its_own_a_nested_groups_and_a_compound_paths_gradients_by_their_bounds`, `group::tests::shape_art_moves_the_gradients_of_a_symbol_instances_art_and_a_compound_shape_with_a_warp`; render `objectfx::a_paths_gradient_moves_and_scales_with_its_warped_bounds`; engine `objexpand::a_placed_gradient_moved_by_a_warp_expands_as_the_canvas_draws_it`). Scores and hours unchanged. - **Warp as a Bézier patch per style:** each of the fifteen Warp styles bends the object's bounds as one Bézier patch whose control points the style sets from the bend and the size of the bounds; the horizontal distortion then scales each column of control points about its middle and the vertical distortion scales each row. The Warp effects and warp envelopes share the map, and content with no width or no height takes the middle of the patch along that side. New warps, the built-in graphic styles' Warps and warps opened from `.ai` files carry `version` 1 for it. Warps in `.vectorcraft` documents saved before it read as `version` 0 and keep their earlier look, and edits keep a warp's version; a `version` 0 warp saved to `.ai` opens again with `version` 1, since the file holds only its settings. The patches are VectorCraft's own rules; the styles and ranges are those of the Warp options in [Distort and transform effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html) (doc `live::tests::warp_nets_bend_each_style_as_its_own_patch`, `warp_net_arc_edges_are_concentric_circles`, `warp_net_distortion_scales_the_columns_then_the_rows`, `warp_net_takes_any_numbers`, `warp_net_bends_a_huge_box_without_overflowing`, `warp_envelopes_bend_flat_content_as_the_middle_of_the_patch`, `warp_envelopes_write_their_map_version_only_when_it_has_one`, `style_libs::tests::builtin_warp_styles_take_the_nets_map`; effects `tests::a_warp_draws_with_the_map_its_version_names`, `tests::a_warp_bends_a_flat_box_as_the_middle_of_its_patch`, `tests::new_warps_take_the_nets_version`; engine `effectedit::warps_keep_the_map_their_version_names`, `envelope::new_warp_envelopes_take_the_net_and_saved_ones_keep_their_map`; eps `ai_editing::tests::every_effect_a_graphic_style_holds_is_read_back`, `import::ai::tests::a_warp_envelope_opens_live`, `import::ai::tests::warp_envelopes_read_every_style`, `import::ai::style::tests::a_stack_of_fills_with_effects_of_their_own_is_read_as_itself`; ui-egui `dialogs::tests_warp::warp_options_edit_keeps_each_targets_map_version`). Scores and hours unchanged. - **Warp effects bend images:** a Warp effect on an image, or on a group that holds one, bends the image's pixels with its frame, as the Warp effect is documented to distort bitmap images in [Reshaping with effects](https://helpx.adobe.com/illustrator/using/reshaping-objects-effects.html): after the last effect, the image is cut into 10 × 10 cells of two triangles, each a clipped piece under its own affine map. Effects after the Warp reshape only the frame, a second Warp bends the same pieces, and warps saved without a version bend the pixels with their earlier map, as they bend the frame. A placed gradient on a group that holds the image follows the group's bounds as the pieces draw them. SVG writes the image once for the pieces; PDF, EPS, `.ai` and PNG write the bent pieces. The images stay unbent when their pieces would make the art pass 2 million objects, and so does an image whose frame keeps its shape past the anchor budget. A grayscale image's fill color now colorizes the pieces of a warp or an envelope, and an image under any geometry effect keeps that color and its smoothing setting. Existing documents with a Warp effect on an image, or on a group that holds one, draw differently: before, the image showed unbent, cut by the bent frame. So do existing documents with a colorized image under an envelope or a geometry effect, or an image drawn without smoothing under a geometry effect: before, those lost the color or the setting. The cell count and the limits are VectorCraft's own (effects `tests::warped_images_stay_within_the_object_budget`, `tests::warped_images_keep_document_keys_and_hidden_images_apart`, `tests::an_image_whose_frame_passes_the_budget_stays_whole`, `tests::effects_after_a_warp_reshape_an_images_frame_and_not_its_pieces`, `tests::an_images_pixels_take_the_map_their_warps_version_names`, `tests::an_image_under_a_geometry_effect_keeps_its_color_and_smoothing`, `tests::a_nested_groups_gradient_follows_its_warped_image`; engine `warp_images::an_images_pixels_bend_with_effect_warp`, `a_warped_image_keeps_its_bend_in_svg`, `a_warped_image_keeps_its_bend_in_pdf`, `a_warped_image_keeps_its_bend_in_ai`, `a_warped_image_keeps_its_bend_in_eps`, `a_warped_image_keeps_its_bend_in_png`, `svg_writes_an_image_clipped_twice_in_a_users_group_twice`, `expand_appearance_of_a_warped_image_draws_as_the_canvas_did`, `a_colorized_image_keeps_its_color_when_warped`, `a_warp_on_a_group_bends_the_image_in_it`, `envelope_distort::a_colorized_image_keeps_its_color_in_an_envelope`). Scores and hours unchanged. +- **EPS writes a repeated image's samples once:** an image drawn more than once in an EPS file (the pieces of an envelope or a Warp effect, copies of one image, or one image in a symbol's instances) has its compressed samples written once in the setup, and each placement reads them from there: a reusable stream rewound for each placement at Language Level 3 ([PostScript Language Reference, 3rd ed., §3.13](https://www.adobe.com/jp/print/postscript/pdfs/PLRM.pdf)), and an array of strings of at most 49,152 bytes fed to the image by a procedure at Level 2. An image drawn once is written where it is drawn, as before. The 256 MB limit on the page counts the samples once, so an envelope of a large photo no longer loses pieces to it. A colorized image is reused per ink (eps `tests::an_image_placed_twice_writes_its_samples_once_at_both_levels`, `tests::an_envelopes_image_pieces_draw_one_copy_of_its_samples`, `tests::each_ink_of_a_colorized_image_placed_twice_is_written_once`, `tests::samples_longer_than_a_string_are_reused_at_both_levels`, `tests::the_page_limit_counts_a_repeated_images_samples_once`). Scores and hours unchanged. - **Distort Appearance with Transform effect options:** with Envelope Options › Distort Appearance on, a path whose Transform effect has Scale Strokes & Effects or Transform Patterns on, or copies it with a gradient, is bent as the same per-copy art it draws and expands as outside the envelope: each copy's own stroke weight, dashes, pattern placement and gradient (effects `bake::tests`). Scores and hours unchanged. - **Fitted gradients turn with the Transform effect:** a gradient fitted to the object (not placed with the Gradient tool) turns, reflects and stretches with the object when its Transform effect rotates, reflects or scales it unevenly, as Object › Transform already does: the gradient is fixed to its fit on the geometry the effect takes, then follows the effect and the effects after it as a placed gradient does. Under moves and even scales it draws as a refit does, and a Transform effect that scales the object to nothing across leaves it fitted, with Scale Strokes & Effects or Transform Patterns on too, and on each copy. This holds on the canvas and in Object › Expand of the fill, for an object's own effects, a fill's or stroke's own, and a group's, where the gradients fitted to its paths, to its own fill, to the groups inside it and to its images' and symbol instances' own fills each fix to their own object's fit. Existing documents with such a Transform effect on fitted-gradient art draw differently: before, the gradient refit to the turned outline. The Gradient tool still shows a fitted gradient's vector at its unturned fit. How gradients follow the effect is VectorCraft's own rule; what the effect does to the object is described in [Distort and transform effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html) (effects `bake::tests::a_fitted_gradient_turns_reflects_and_stretches_with_a_transform_effect`, `bake::tests::a_transform_effect_that_scales_to_nothing_leaves_a_fitted_gradient_fitted`, `group::tests::a_groups_transform_turns_the_gradients_fitted_to_its_members_and_its_own_fill`; render `objectfx::a_fitted_gradient_turns_and_reflects_with_its_transform_on_the_canvas`; engine `objexpand::a_fitted_gradient_turned_by_a_transform_expands_as_the_canvas_draws_it`). Scores and hours unchanged. - **Placed gradients follow Transform and Free Distort effects:** a gradient placed with the Gradient tool moves, turns and scales with the object when the object's Transform effect moves its outline, and each copy of the Transform effect takes it as many times as it takes the transform. Under Free Distort it moves and scales from the path's bounds before the effect to its bounds after it. This holds on the canvas, in export, in Expand Appearance, in Pathfinder and compound shapes, and in Object › Expand of the fill, for an object's own effects, a group's (the own fills of the group, of the groups inside it, of a symbol's art, and of the images and symbol instances it moves, included) and a fill's or stroke's own. A gradient on a path inside a group with a Transform effect now moves with the path (#2214 moved only the gradients of type), and so does a gradient on an image's or symbol instance's own fill. When a path keeps its shape because the effect would pass the geometry budget, the group's own gradients stay in place; a hidden path in a layer, which the layer's bounds leave out, does not hold them. A freeform gradient whose points were edited moves its points in the same way. How gradients follow each effect is VectorCraft's own rule; what each effect does to the object is described in [Distort and transform effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html) (effects `bake::tests::a_baked_transform_moves_a_placed_gradient_with_the_path`, `bake::tests::a_strokes_own_transform_and_free_distort_move_its_placed_gradient`, `bake::tests::a_fills_gradient_follows_the_effects_around_its_transform_with_options`, `bake::tests::baked_free_distort_moves_placed_gradients_from_the_bounds_to_the_distorted_bounds`, `group::tests::a_groups_transform_and_free_distort_move_the_gradients_placed_on_its_paths`, `group::tests::a_groups_transform_moves_the_gradient_of_its_own_fill`, `group::tests::a_nested_groups_own_gradient_follows_the_outer_groups_transform_and_free_distort`, `group::tests::a_groups_transform_moves_the_gradients_placed_on_an_images_own_fills`, `group::tests::shape_art_moves_gradients_with_a_transform_effect`, `group::tests::shape_art_moves_the_gradient_of_a_symbol_instances_art_with_its_transform`, `tests::art_past_the_budget_keeps_its_own_gradients_in_place`, `tests::a_hidden_path_past_the_budget_leaves_a_layers_gradients_free_to_move`, `tests::edited_freeform_points_follow_a_transform_effect_and_free_distort`; render `objectfx::a_paths_gradient_moves_with_its_transform_and_free_distort`; engine `objexpand::a_placed_gradient_moved_by_a_transform_or_free_distort_expands_as_the_canvas_draws_it`). Scores and hours unchanged.- **Constrain Angle (#394):** General › Constrain Angle turns the arrow keys' axes (guides stay on the page's), holds Shift drags of the Selection, Direct Selection, Free Transform, Slice Selection, Reshape and Measure tools and the Pen's handles to the turned axes, and new rectangles, rounded rectangles, ellipses, grids, point and area type, Shift-drawn polygons and stars and the shape size dialogs are drawn along it with their bounding box turned; 0° and whole turns are exactly as before, quarter turns exact (). Tests: `tests_constrain_tools`, `tests_constrain_new` (engine), `geom` `constrain_axes` / `rotate_deg`, the shape, grid and type tool tests.