Merge pull request #7782 from dotCooCoo/fix/a-colorized-warped-image-decodes-its-coverage

Legacy .ai: a colorized warped image works out its coverage once
This commit is contained in:
BFlat
2026-11-15 15:44:35 +07:00
committed by GitHub
3 changed files with 67 additions and 1 deletions
+35
View File
@@ -790,6 +790,41 @@ fn colorized_images_keep_their_color() {
assert_eq!(out.warnings, [IMAGE_ALPHA]);
}
/// The pieces of a colorized warped image work out the image's coverage once, and an image with
/// other data works out its own: a black and white image's pieces are written as image masks
/// in its color, and a gray image's pieces with its color in their pixels.
#[test]
fn the_pieces_of_a_colorized_warped_image_work_out_its_coverage_once() {
// GUARDS: each piece of a colorized warped image decoded the image again for its coverage.
let mut doc = Document::new(400.0, 200.0);
doc.layers.clear();
let layer = doc.add_layer(Some("Art"));
let paint = Paint::solid(Color::cmyk(0.0, 0.5, 1.0, 0.0));
let checks: Vec<u8> = (0..16 * 16).map(|i| if (i % 16 + i / 16) % 2 == 0 { 0 } else { 255 }).collect();
let ramp: Vec<u8> = (0..16 * 16).map(|i| (i % 16 * 16) as u8).collect();
for (gray, x) in [(checks, 10.0), (ramp, 210.0)] {
let photo = colorized_image(&mut doc, (16, 16), gray, &paint, (x, 10.0));
let NodeKind::Image(im) = &photo.kind else { panic!("{:?}", photo.kind) };
let bend = move |p: vectorcraft_geom::Point| vectorcraft_geom::Point::new(p.x, p.y + 8.0 * ((p.x - x) / 160.0 * std::f64::consts::PI).sin());
let warped = vectorcraft_doc::live::mesh_warp_image(&photo, im, &bend, 4, false, false);
doc.insert(Some(layer), usize::MAX, warped).unwrap();
}
super::writer::COVERAGE_RUNS.with(|n| n.set(0));
let out = write(&doc);
assert_eq!(super::writer::COVERAGE_RUNS.with(std::cell::Cell::get), 2, "once for each image");
let text = String::from_utf8(out.bytes).unwrap();
let images = rasters(&text);
assert_eq!(images.len(), 64, "32 pieces of each image");
let xi = |piece: &Vec<&str>| piece.iter().find(|l| l.ends_with(" XI")).map(|l| l.to_string()).unwrap_or_default();
assert!(images[..32].iter().all(|p| xi(p).ends_with(" 1 1 0 0 0 1 XI")), "{:?}", images[..32].iter().map(xi).collect::<Vec<_>>());
assert!(images[32..].iter().all(|p| xi(p).ends_with(" 8 3 0 0 0 0 XI")), "{:?}", images[32..].iter().map(xi).collect::<Vec<_>>());
// The first piece holds the whole image: each row of 16 pixels in two bytes, its black pixels 0 bits.
assert!(xi(&images[0]).ends_with(" ] 0 0 16 16 16 16 1 1 0 0 0 1 XI"), "{}", xi(&images[0]));
let data: Vec<&str> = images[0].iter().filter_map(|l| l.strip_prefix('%')).filter(|l| l.bytes().all(|b| b.is_ascii_hexdigit())).collect();
assert_eq!(data.concat(), "5555AAAA".repeat(8));
assert_eq!(out.warnings, [IMAGE_ALPHA]);
}
/// An image mask is only for a colorized image the writer draws as the document does: not one
/// with an opacity mask turned on, and not one with more pixels than the writer writes.
#[test]
+31 -1
View File
@@ -11,6 +11,15 @@ use super::{
};
use super::{CONIC, CONIC_WEDGES, DIAMOND, GRAPHS, HUE_STOPS, SPREAD, SPREAD_CAPPED};
#[cfg(test)]
thread_local! {
/// How many times [`Writer::coverage`] has worked out an image's coverage on this thread.
pub(super) static COVERAGE_RUNS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
/// An image's coverage ([`ImageBlob::coverage`]): its width, its height and one byte a pixel.
type Coverage = (u32, u32, Arc<Vec<u8>>);
pub(super) struct Writer<'a> {
doc: &'a Document,
/// The document → art space.
@@ -50,6 +59,9 @@ pub(super) struct Writer<'a> {
/// The image decoded last (`None` when it can't be): the pieces of a warped image, which follow
/// one another, decode it once.
last_image: Option<(ImageBlob, Option<Arc<image::DynamicImage>>)>,
/// The coverage of [`Self::last_image`] once a colorized piece of it asked for it (`Some(None)`
/// when it can't be worked out), so the pieces of a colorized warped image work it out once.
last_coverage: Option<Option<Coverage>>,
/// The images written whole so far, by the address of their data (which each entry keeps).
whole_images: std::collections::HashMap<usize, Arc<Vec<u8>>>,
/// Writing a graph's art with its records: a graph inside it is written as its art.
@@ -79,6 +91,7 @@ impl<'a> Writer<'a> {
kumi: false,
clips: vec![],
last_image: None,
last_coverage: None,
whole_images: std::collections::HashMap::new(),
in_graph: false,
}
@@ -1104,7 +1117,7 @@ impl<'a> Writer<'a> {
let fit = Affine::scale_non_uniform(f64::from(width.max(1)) / f64::from(w), f64::from(height.max(1)) / f64::from(h));
let coverage = match colorize {
Some(paint) => {
let Some((_, _, coverage)) = ImageBlob::coverage(blob).filter(|(cw, ch, _)| (*cw, *ch) == (w, h)) else {
let Some((_, _, coverage)) = self.coverage(blob).filter(|(cw, ch, _)| (*cw, *ch) == (w, h)) else {
self.warn(UNDECODED);
return;
};
@@ -1172,9 +1185,26 @@ impl<'a> Writer<'a> {
}
let img = image::load_from_memory(&blob.bytes).ok().map(Arc::new);
self.last_image = Some((blob.clone(), img.clone()));
self.last_coverage = None;
img
}
/// The coverage of `blob` ([`ImageBlob::coverage`]), worked out once for images that follow one
/// another with the same data, as [`Self::decoded`] decodes them.
fn coverage(&mut self, blob: &ImageBlob) -> Option<Coverage> {
let last = self.last_image.as_ref().is_some_and(|(last, _)| Arc::ptr_eq(&last.bytes, &blob.bytes) && last.mime == blob.mime);
if last && let Some(coverage) = &self.last_coverage {
return coverage.clone();
}
#[cfg(test)]
COVERAGE_RUNS.with(|n| n.set(n.get() + 1));
let coverage = ImageBlob::coverage(blob).map(|(w, h, c)| (w, h, Arc::new(c)));
if last {
self.last_coverage = Some(coverage.clone());
}
coverage
}
/// An image's lines (§8.4): `%AI5_File:`, `%AI5_BeginRaster`, matrix `m` as `[ a b c d tx ty ]`
/// and the numbers `operands` with `XI`, `samples` in hex on comment lines of [`HEX_LINE`]
/// digits, `%AI5_EndRaster`, then `F`.
+1
View File
@@ -640,6 +640,7 @@ since 2026-10-01.
- **`.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.
- **`.ai` files write a warped image's pieces as the parts they show:** in the `.ai` editing data and in legacy `.ai` files, an embedded image cut to a clipping path (in the editing data, also an image under an opacity mask that clips) is written with all of its pixels the first time, and every later copy of the same pixels is written as the rectangle of them that the path or mask shows, 2 pixels more on each side, placed where it lies in the image. The pieces of a Warp effect, an envelope or their expanded art, 200 copies of the image for 10 × 10 cells before, now hold about 7 times the image's pixels in all for a 64 × 64 image. A copy whose opacity mask is left out, nested too deep or past the object limit, keeps all of its pixels. Each image's pixels are read once for the pieces that follow one another. An image cut to a rectangle of half its pixels or more, and the first copy written, keep all of their pixels. The `XI` operator's arguments give an image's location and size ([AI file-format specification, §8.1](https://web.archive.org/web/20050126105313/https://partners.adobe.com/public/developer/en/illustrator/sdk/AI7FileFormat.pdf)); the 2-pixel margin and the half-the-pixels limit are VectorCraft's own rules (eps `ai_editing::tests::the_pieces_of_a_warped_image_are_written_as_the_parts_they_show`, `masked_pieces_of_a_warped_image_are_written_as_the_parts_they_show`, `an_image_with_a_live_warp_writes_its_pixels_whole_once`, `a_clipped_image_written_first_keeps_all_of_its_pixels`, `a_masked_copy_whose_mask_is_left_out_keeps_all_of_its_pixels`, `a_masked_copy_nested_too_deep_for_its_mask_keeps_all_of_its_pixels`, `legacy_ai::tests::the_pieces_of_a_warped_image_are_written_as_the_parts_they_show`). Scores and hours unchanged.
- **Legacy `.ai` files work out a colorized warped image's coverage once:** for the pieces of a colorized image cut by a Warp effect, an envelope or their expanded art, a legacy `.ai` save works out how much of the color each pixel shows once for the pieces that follow one another, and no longer decodes the image again for each piece. The file written is unchanged (eps `legacy_ai::tests::the_pieces_of_a_colorized_warped_image_work_out_its_coverage_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.