mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-14 14:41:52 +00:00
Merge pull request #7500 from dotCooCoo/fix/eps-freeform-field-once
EPS: a freeform gradient's image drawn more than once writes its samples once
This commit is contained in:
+64
-19
@@ -92,6 +92,19 @@ struct ImageCode {
|
||||
}
|
||||
|
||||
impl ImageCode {
|
||||
/// A hash of everything that is written for the image: equal images have equal keys.
|
||||
fn content_key(&self) -> u64 {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
(&self.prelude, &self.dict, self.filter, self.op, &self.ascii).hash(&mut h);
|
||||
h.finish()
|
||||
}
|
||||
|
||||
/// Is everything written for `other` the same as for this image?
|
||||
fn same_image(&self, other: &ImageCode) -> bool {
|
||||
self.prelude == other.prelude && self.dict == other.dict && self.filter == other.filter && self.op == other.op && self.ascii == other.ascii
|
||||
}
|
||||
|
||||
/// The operators and data that draw the image where it is placed.
|
||||
fn push_inline(&self, s: &mut String) {
|
||||
s.push_str(&self.prelude);
|
||||
@@ -253,6 +266,9 @@ pub(crate) struct Scene<'a> {
|
||||
images: HashMap<String, Option<usize>>,
|
||||
/// The source key and solid paint together identify a colorized encoding.
|
||||
colorized_images: HashMap<(String, String), Option<usize>>,
|
||||
/// Freeform gradients' images by [`ImageCode::content_key`] → their indices in `codes`: a field
|
||||
/// encoded the same as one already kept draws from it.
|
||||
fields: HashMap<u64, Vec<usize>>,
|
||||
/// The encoded images.
|
||||
codes: Vec<ImageCode>,
|
||||
/// Where in `body` each placement of an encoded image goes (in order) and the index of its code.
|
||||
@@ -281,6 +297,7 @@ impl<'a> Scene<'a> {
|
||||
brushes: None,
|
||||
images: HashMap::new(),
|
||||
colorized_images: HashMap::new(),
|
||||
fields: HashMap::new(),
|
||||
codes: vec![],
|
||||
placements: vec![],
|
||||
pending: 0,
|
||||
@@ -478,7 +495,7 @@ impl<'a> Scene<'a> {
|
||||
_ => {}
|
||||
}
|
||||
self.note_losses(n);
|
||||
let (was, placed) = (self.body.len(), self.placements.len());
|
||||
let (was, placed, codes) = (self.body.len(), self.placements.len(), self.codes.len());
|
||||
match &n.kind {
|
||||
NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } => {
|
||||
children.iter().for_each(|c| self.node(c, false));
|
||||
@@ -536,6 +553,7 @@ impl<'a> Scene<'a> {
|
||||
if self.size() > self.max_body {
|
||||
self.body.truncate(was);
|
||||
self.unplace(placed);
|
||||
self.forget_fields(codes);
|
||||
self.warn(TOO_LARGE);
|
||||
}
|
||||
}
|
||||
@@ -1364,6 +1382,14 @@ impl<'a> Scene<'a> {
|
||||
if !(long > 1e-9 && long.is_finite()) {
|
||||
return;
|
||||
}
|
||||
let place = Affine::translate(area.origin().to_vec2()) * Affine::scale_non_uniform(area.width(), area.height());
|
||||
if !invertible(place) {
|
||||
return;
|
||||
}
|
||||
if self.size().saturating_add(64) > self.max_body {
|
||||
self.warn(TOO_LARGE);
|
||||
return;
|
||||
}
|
||||
let k = (self.doc.raster_effects_ppi / 72.0).min(MAX_FIELD_PX / long);
|
||||
let along = |len: f64| (len * k).ceil().clamp(1.0, MAX_FIELD_PX) as u16;
|
||||
let (cols, rows) = (along(area.width()), along(area.height()));
|
||||
@@ -1383,9 +1409,43 @@ impl<'a> Scene<'a> {
|
||||
let rgba: Vec<u8> = field.display_grid(area, cols, rows).flat_map(|(c, a)| [q(c[0]), q(c[1]), q(c[2]), q(a)]).collect();
|
||||
self.encode_image(&rgba, w, h)
|
||||
};
|
||||
let place = Affine::translate(area.origin().to_vec2()) * Affine::scale_non_uniform(area.width(), area.height());
|
||||
if let Some(image) = image {
|
||||
self.place_inline(&image, place);
|
||||
let Some(image) = image else { return };
|
||||
let key = image.content_key();
|
||||
let found =
|
||||
self.fields.get(&key).and_then(|bucket| bucket.iter().copied().find(|&i| self.codes.get(i).is_some_and(|c| c.same_image(&image))));
|
||||
let i = match found {
|
||||
Some(i) => i,
|
||||
None => {
|
||||
// A field that can't be placed isn't kept.
|
||||
if self.size().saturating_add(image.inline_len).saturating_add(64) > self.max_body {
|
||||
self.warn(TOO_LARGE);
|
||||
return;
|
||||
}
|
||||
let i = self.add_code(image);
|
||||
self.fields.entry(key).or_default().push(i);
|
||||
i
|
||||
}
|
||||
};
|
||||
self.place_image(i, place);
|
||||
}
|
||||
|
||||
/// Forget the field images after the first `n` codes that no placement draws any longer, last
|
||||
/// first, up to the first code that is drawn or isn't a field image.
|
||||
fn forget_fields(&mut self, n: usize) {
|
||||
while self.codes.len() > n {
|
||||
let i = self.codes.len() - 1;
|
||||
let Some(code) = self.codes.get(i) else { break };
|
||||
if code.uses > 0 {
|
||||
break;
|
||||
}
|
||||
let key = code.content_key();
|
||||
let Some(bucket) = self.fields.get_mut(&key) else { break };
|
||||
let Some(at) = bucket.iter().position(|&j| j == i) else { break };
|
||||
bucket.swap_remove(at);
|
||||
if bucket.is_empty() {
|
||||
self.fields.remove(&key);
|
||||
}
|
||||
self.codes.pop();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1595,21 +1655,6 @@ impl<'a> Scene<'a> {
|
||||
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");
|
||||
}
|
||||
|
||||
/// Straight RGBA pixels (`w` × `h`, rows from the top) as the operators and data that draw
|
||||
/// 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
|
||||
|
||||
@@ -1264,6 +1264,104 @@ fn the_page_limit_counts_a_repeated_images_samples_once() {
|
||||
assert!(page.body.len() + page.setup.len() <= inline + 1000 + 400, "{}", page.body.len());
|
||||
}
|
||||
|
||||
/// A freeform gradient of `points` (x, y and color).
|
||||
fn freeform(points: &[(f64, f64, Color)]) -> Paint {
|
||||
let mut g = GradientPaint::new(Gradient { kind: GradientKind::Freeform, ..Default::default() });
|
||||
g.freeform = Some(Freeform { points: points.iter().map(|&(x, y, c)| FreeformPoint::new(Point::new(x, y), c)).collect(), ..Default::default() });
|
||||
Paint::Gradient(Box::new(g))
|
||||
}
|
||||
|
||||
/// A square at (`x`, `y`) of side `side`, filled with a freeform gradient of `a` and `b` at a
|
||||
/// fifth and four fifths of its width.
|
||||
fn freeform_square(d: &mut Document, x: f64, y: f64, side: f64, a: Color, b: Color) -> Node {
|
||||
let mid = y + side / 2.0;
|
||||
let paint = freeform(&[(x + side / 5.0, mid, a), (x + side * 0.8, mid, b)]);
|
||||
path(d, shapes::rectangle(Rect::new(x, y, x + side, y + side)), paint, Paint::None, 0.0)
|
||||
}
|
||||
|
||||
/// A freeform gradient on a shape and on a moved copy of it is sampled the same for both: its
|
||||
/// image's samples are written once, at both levels, in RGB and in CMYK.
|
||||
#[test]
|
||||
fn a_freeform_gradients_image_drawn_twice_writes_its_samples_once() {
|
||||
let rgb = (Color::rgb(1.0, 0.0, 0.0), Color::rgb(0.0, 0.0, 1.0));
|
||||
let cmyk = (Color::cmyk(1.0, 0.0, 0.0, 0.0), Color::cmyk(0.0, 0.0, 0.0, 1.0));
|
||||
for (a, b) in [rgb, cmyk] {
|
||||
let single = doc_with(|d| vec![freeform_square(d, 0.0, 0.0, 64.0, a, b)]);
|
||||
let twice = doc_with(|d| vec![freeform_square(d, 0.0, 0.0, 64.0, a, b), freeform_square(d, 64.0, 64.0, 64.0, a, b)]);
|
||||
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"), "a field drawn once is written where it is drawn: {one_ps}");
|
||||
let out = export(&twice, &EpsOptions { level, ..opts(&twice) }, 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}");
|
||||
assert_eq!(reused_samples(&ps, level), inline_samples(&one_ps, level), "{level:?}");
|
||||
assert_eq!(out.warnings, one.warnings);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fields sampled differently are each written where they are drawn: different gradients, and one
|
||||
/// gradient on shapes of different sizes.
|
||||
#[test]
|
||||
fn freeform_gradients_sampled_differently_dont_share_an_image() {
|
||||
let (red, blue, green) = (Color::rgb(1.0, 0.0, 0.0), Color::rgb(0.0, 0.0, 1.0), Color::rgb(0.0, 1.0, 0.0));
|
||||
let colors = doc_with(|d| vec![freeform_square(d, 0.0, 0.0, 64.0, red, blue), freeform_square(d, 0.0, 0.0, 64.0, red, green)]);
|
||||
let sizes = doc_with(|d| {
|
||||
let paint = freeform(&[(20.0, 50.0, red), (80.0, 50.0, blue)]);
|
||||
vec![
|
||||
path(d, shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), paint.clone(), Paint::None, 0.0),
|
||||
path(d, shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 100.0)), paint, Paint::None, 0.0),
|
||||
]
|
||||
});
|
||||
for d in [colors, sizes] {
|
||||
for level in Level::ALL {
|
||||
let ps = page_of(&d, &EpsOptions { level, ..opts(&d) });
|
||||
assert!(!ps.contains("VCim"), "{ps}");
|
||||
assert_eq!(ps.matches("} exec\n").count(), 2, "{ps}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A field's second placement that passes the page limit is left out, and the first is written
|
||||
/// where it is drawn. A field taken back with the shape that passed the limit leaves nothing in the
|
||||
/// setup.
|
||||
#[test]
|
||||
fn a_field_taken_back_at_the_page_limit_leaves_no_image_behind() {
|
||||
let (red, blue) = (Color::rgb(1.0, 0.0, 0.0), Color::rgb(0.0, 0.0, 1.0));
|
||||
let single = doc_with(|d| vec![freeform_square(d, 0.0, 0.0, 64.0, red, blue)]);
|
||||
let o = opts(&single);
|
||||
let inline = crate::scene::Scene::new(&single, &o).run().body;
|
||||
let twice = doc_with(|d| vec![freeform_square(d, 0.0, 0.0, 64.0, red, blue), freeform_square(d, 0.0, 0.0, 64.0, red, blue)]);
|
||||
let page = crate::scene::Scene::new(&twice, &o).with_max_body(inline.len() + 50).run();
|
||||
assert!(page.warnings.iter().any(|w| w.contains("too large")), "{:?}", page.warnings);
|
||||
assert!(page.body.starts_with(&inline), "{}", page.body);
|
||||
assert_eq!(page.body.matches("} exec\n").count(), 1, "{}", page.body);
|
||||
assert!(!page.setup.contains("VCim"), "{}", page.setup);
|
||||
// A many-sided shape whose stroke passes the limit after its fill's field was placed.
|
||||
let big = |d: &mut Document, stroke: Paint| {
|
||||
let paint = freeform(&[(60.0, 100.0, Color::rgb(0.0, 1.0, 0.0)), (140.0, 100.0, red)]);
|
||||
path(d, shapes::polygon(Point::new(100.0, 100.0), 90.0, 2000, 0.0), paint, stroke, 1.0)
|
||||
};
|
||||
let filled = doc_with(|d| vec![big(d, Paint::None)]);
|
||||
let stroked = doc_with(|d| vec![big(d, Paint::solid(Color::rgb(0.0, 0.0, 0.0)))]);
|
||||
let fill = crate::scene::Scene::new(&filled, &o).run().body.len();
|
||||
let both = crate::scene::Scene::new(&stroked, &o).run().body.len();
|
||||
assert!(both > fill + 20_000, "{fill} {both}");
|
||||
let d = doc_with(|d| {
|
||||
let s = big(d, Paint::solid(Color::rgb(0.0, 0.0, 0.0)));
|
||||
vec![s, freeform_square(d, 0.0, 0.0, 64.0, red, blue), freeform_square(d, 0.0, 0.0, 64.0, red, blue)]
|
||||
});
|
||||
let page = crate::scene::Scene::new(&d, &o).with_max_body(fill + (both - fill) / 2).run();
|
||||
assert!(page.warnings.iter().any(|w| w.contains("too large")), "{:?}", page.warnings);
|
||||
assert_eq!(reuses(&page.body, 0, Level::Three), 2, "{}", page.body);
|
||||
assert!(page.setup.contains("/VCim0 VCsrc") && !page.setup.contains("VCim1"), "{}", page.setup);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_length_and_ascii85_round_trip() {
|
||||
let cycle: Vec<u8> = (0..=255u8).cycle().take(1000).collect();
|
||||
|
||||
@@ -626,6 +626,7 @@ since 2026-10-01.
|
||||
- **`.ai` files keep an image's Warp live:** an embedded image whose appearance is one Warp effect, with no opacity mask, is saved to the `.ai` editing data with an art style of that Warp: its bent pieces are its drawn look, and the image itself follows on `%_` lines, as for other styled objects. Opening an `.ai` file reads such an image as the image with its Warp live, its pixels, placement, name and opacity kept, when the live warp draws as the file's drawn look and the import has a comparison left; otherwise the drawn look stays, with the drawn-look note. The Warp effect applies to bitmap images ([the help page](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html)), and native files keep live effects ([the help page](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/supported-file-formats-in-illustrator.html)). Images with an opacity mask, and images with a Warp and another effect or with another geometry effect, are saved and opened as their drawn art. Existing `.ai` files with an embedded image on `%_` lines open differently: before, that image also came in beside its drawn look. Which images keep the Warp and the comparison are VectorCraft's own rules (eps `ai_editing::tests::an_image_with_a_warp_reads_back_with_its_warp_live`, `an_image_whose_warp_draws_otherwise_than_its_look_keeps_its_look`, `an_image_on_hidden_lines_does_not_come_in_beside_its_look`, `other_warped_images_are_written_as_their_drawn_art`, `import::ai::read::ops::tests::an_image_takes_a_style_of_one_warp_alone`). Scores and hours unchanged.
|
||||
- **`.ai` files keep a linked image's Warp live and its link:** a linked image whose appearance is one Warp effect, with no opacity mask, is saved to the `.ai` editing data with an art style of that Warp, as an embedded one is: its bent pieces are its drawn look, and the image itself follows on `%_` lines as a placed file with its path, its folder relative to the document's and its preview. Before, it was saved as its drawn art and its live Warp was lost. Opening the file reads it as the linked image with its Warp live, its link, pixels, placement, name, opacity, hide and lock kept, under the same drawn-look comparison; a hidden image with a Warp, embedded or linked, is now compared with its look drawn shown, and keeps its Warp too. A linked image with a Warp in an opacity mask's art keeps its Warp and is embedded, with the existing warning for linked images in masks. Linked files stay connected to their source and track it by relative path ([the help page](https://helpx.adobe.com/illustrator/desktop/add-and-import-files/manage-linked-and-embedded-files/links-panel-overview.html)); the placed-file form is the one the `.ai` reader reads (`crates/eps/src/import/ai/read/art.rs` `end_place`). eps `ai_editing::tests::a_linked_image_with_a_warp_reads_back_linked_with_its_warp_live`, `a_large_linked_image_with_a_warp_reads_back_from_its_preview`, `a_hidden_or_locked_image_with_a_warp_keeps_its_state`, `a_linked_image_with_a_warp_in_a_mask_is_embedded_with_its_warp_live`. 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.
|
||||
- **EPS writes a repeated freeform gradient's image once:** a freeform gradient whose image samples the same on more than one fill (copies of a shape moved with their points, or one shape in a symbol's instances) has its samples written once in the setup, as a repeated image's are, and a fill whose image differs (other colors, another size) gets its own. A freeform fill drawn once is written where it is drawn, as before, and a fill whose placement cannot be drawn, or that arrives when the page is already at the 256 MB limit, is no longer sampled first (eps `tests::a_freeform_gradients_image_drawn_twice_writes_its_samples_once`, `tests::freeform_gradients_sampled_differently_dont_share_an_image`, `tests::a_field_taken_back_at_the_page_limit_leaves_no_image_behind`). Scores and hours unchanged.
|
||||
- **PDF writes a warped image once:** with downsampling on, the pieces of an image bent by a Warp effect are resampled for the widest and the highest size any piece places the image at, so every piece paints one image XObject (a colorized image: one colorized image and its soft mask), instead of one image for each size a piece is placed at. Another placement of the same image is still resampled for its own size. Without downsampling, a colorized image and a CMYK image with a grayscale image mask on its grid are decoded and encoded once for all the sizes they are placed at (pdf `tests::compression::a_warped_image_is_written_once_for_all_its_pieces`, `tests::colorize::a_colorized_warped_image_is_written_once_for_all_its_pieces`, `export::images::tests::colorized_and_grid_masked_images_are_made_once_for_every_size_without_downsampling`). 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.
|
||||
|
||||
Reference in New Issue
Block a user