From d16123efad6af0e05797909c7cb596da33efacc7 Mon Sep 17 00:00:00 2001 From: Don Omondi <282319807+mockydevs@users.noreply.github.com> Date: Fri, 6 Nov 2026 09:48:55 -0500 Subject: [PATCH] Legacy .ai export: a flattened image's clip follows its edge smoothly (#5264) The clipping path around a transparent image flattened for a version 7 .ai followed 2 pt cells, a visible staircase. It now follows the rendered pixels (in blocks only past 4 million), grown by one pixel so the whole anti-aliased edge is inside, through the middle of each pixel edge and simplified with Douglas-Peucker, so slopes and curves are sloped lines. --- crates/engine/src/cmd/fileio/legacy_ai.rs | 98 ++++++++++++++++++----- crates/engine/src/tests/legacy_ai.rs | 48 +++++++++++ docs/file-format-parity.md | 5 +- 3 files changed, 129 insertions(+), 22 deletions(-) diff --git a/crates/engine/src/cmd/fileio/legacy_ai.rs b/crates/engine/src/cmd/fileio/legacy_ai.rs index 691c0e836..9bf7a311a 100644 --- a/crates/engine/src/cmd/fileio/legacy_ai.rs +++ b/crates/engine/src/cmd/fileio/legacy_ai.rs @@ -14,7 +14,8 @@ //! Images are the exception (`transparentImages`): an image with see-through pixels, opacity or a //! blending mode written opaque is a box hiding the art under it (version 7 images have no alpha //! channel, §8.1). With `flatten` (the default) each becomes an opaque image of itself and the art -//! under it, rendered from the document as it is, clipped to the area where it changes what shows; +//! under it, rendered from the document as it is, clipped to the area where it changes what shows +//! (a smooth outline of its rendered pixels, not a staircase of them); //! the art under it stays as it was. An image in a group with opacity or a blending mode is //! flattened the same way, and a pattern fill that paints a transparent image (see-through //! pixels, or opacity or a blending mode on the image, the fill, the shape or a group it is in) is @@ -253,8 +254,11 @@ const MAX_PPI: f64 = 600.0; const MIN_PPI: f64 = 72.0; /// A rendered pixel channel changes when it changes by more than this. const CHANGED: u8 = 2; -/// The clip outline follows cells of about this many points. -const CELL: f64 = 2.0; +/// The clip outline follows the rendered pixels, in blocks of them when there would be more than +/// this many: a huge image's clip would have millions of anchors. +const MAX_CELLS: usize = 4 << 20; +/// How far (in cells) the clip outline may stray from the staircase it smooths. +const SMOOTH: f64 = 0.35; /// The image `id` (the `at`th node in paint order of `src`) flattened: `src` drawn up to and /// including it, over white, cropped and clipped to the cells where drawing it changes the @@ -288,18 +292,21 @@ fn flattened(src: &Document, at: usize, id: NodeId) -> Result> { if w == 0 || h == 0 || shown.pixels.len() != w * h * 4 || under.pixels.len() != shown.pixels.len() { return Ok(None); } - // The cells where it changes anything. - let c = (CELL * scale).round().max(1.0) as usize; + // The cells where it changes anything: its pixels, or blocks of them for a huge image. + let c = if w * h <= MAX_CELLS { 1 } else { ((w * h) as f64 / MAX_CELLS as f64).sqrt().ceil() as usize }; let (cw, ch) = (w.div_ceil(c), h.div_ceil(c)); - let mut cells = vec![false; cw * ch]; + let mut changed = vec![false; cw * ch]; for (k, (a, b)) in shown.pixels.as_chunks::<4>().0.iter().zip(under.pixels.as_chunks::<4>().0).enumerate() { if a.iter().zip(b).any(|(a, b)| a.abs_diff(*b) > CHANGED) { let (x, y) = (k % w, k / w); - if let Some(cell) = cells.get_mut(y / c * cw + x / c) { + if let Some(cell) = changed.get_mut(y / c * cw + x / c) { *cell = true; } } } + // Grown by a cell, so the clip takes in the whole anti-aliased edge: where the image changes + // nothing it is the art under it, so a clip a little too large shows nothing more. + let cells = grown(&changed, cw, ch); let marked = || cells.iter().enumerate().filter(|(_, on)| **on).map(|(k, _)| (k % cw, k / cw)); let (Some(x0), Some(x1), Some(y0), Some(y1)) = (marked().map(|p| p.0).min(), marked().map(|p| p.0).max(), marked().map(|p| p.1).min(), marked().map(|p| p.1).max()) @@ -316,12 +323,16 @@ fn flattened(src: &Document, at: usize, id: NodeId) -> Result> { let crop = vectorcraft_render::Rendered { width: (px1 - px0) as u32, height: (py1 - py0) as u32, pixels }; let png = crop.to_png().map_err(EngineError::Other)?; let at = |x: usize, y: usize| Point::new(region.x0 + (x.min(w)) as f64 / scale, region.y0 + (y.min(h)) as f64 / scale); + // A cell corner, in cells, as a point of the page. + let (wf, hf, cf) = (w as f64, h as f64, c as f64); + let page = |(x, y): (f64, f64)| Point::new(region.x0 + (x * cf).clamp(0.0, wf) / scale, region.y0 + (y * cf).clamp(0.0, hf) / scale); let mut clip = PathData::new(vec![]); for comp in vectorcraft_trace::trace_mask(&cells, cw, ch) { for lp in std::iter::once(&comp.outer).chain(&comp.holes) { - let pts: Vec<(i32, i32)> = corners(&lp.pts); - let anchors = pts.iter().map(|&(x, y)| Anchor::corner(at(x.max(0) as usize * c, y.max(0) as usize * c))).collect(); - clip.subpaths.push(SubPath::new(anchors, true)); + let anchors: Vec = smoothed(&lp.pts).into_iter().map(|p| Anchor::corner(page(p))).collect(); + if anchors.len() >= 3 { + clip.subpaths.push(SubPath::new(anchors, true)); + } } } let xf = Affine::translate(at(px0, py0).to_vec2()) * Affine::scale(1.0 / scale); @@ -343,17 +354,64 @@ fn up_to(n: &Node, last: usize, i: &mut usize) -> Option { Some(n) } -/// A loop's corners: its points without those in the middle of a straight run. -fn corners(pts: &[(i32, i32)]) -> Vec<(i32, i32)> { +/// `mask` (`w × h` cells) with every cell next to a set one set too. +fn grown(mask: &[bool], w: usize, h: usize) -> Vec { + let set = |x: usize, y: usize| mask.get(y * w + x).copied().unwrap_or(false); + let mut out = vec![false; mask.len()]; + for (k, cell) in out.iter_mut().enumerate() { + let (x, y) = (k % w, k / w); + *cell = (y.saturating_sub(1)..(y + 2).min(h)).any(|y| (x.saturating_sub(1)..(x + 2).min(w)).any(|x| set(x, y))); + } + out +} + +/// A loop of cell corners (a staircase) as a smooth outline, in cells: the middle of each cell +/// edge along it, so that a run of one-cell steps becomes the sloped line it stands for, without +/// the points that lie (within [`SMOOTH`]) on a straight line between their neighbours. +fn smoothed(pts: &[(i32, i32)]) -> Vec<(f64, f64)> { + let mut mids = Vec::with_capacity(pts.len()); + for (k, &(ax, ay)) in pts.iter().enumerate() { + let Some(&(bx, by)) = pts.get(k + 1).or(pts.first()) else { break }; + let (dx, dy) = ((bx - ax).signum(), (by - ay).signum()); + let steps = (bx - ax).unsigned_abs().max((by - ay).unsigned_abs()); + for i in 0..steps as i32 { + let (x, y) = (ax + dx * i, ay + dy * i); + mids.push((f64::from(x) + 0.5 * f64::from(dx), f64::from(y) + 0.5 * f64::from(dy))); + } + } + simplify_closed(&mids, SMOOTH) +} + +/// The points of closed polyline `pts` that Douglas–Peucker keeps with tolerance `tol`. +fn simplify_closed(pts: &[(f64, f64)], tol: f64) -> Vec<(f64, f64)> { let n = pts.len(); - let get = |k: usize| pts.get(k % n.max(1)).copied(); - (0..n) - .filter_map(|k| { - let (a, b, c) = (get(k + n - 1)?, get(k)?, get(k + 1)?); - let straight = (a.0 == b.0 && b.0 == c.0) || (a.1 == b.1 && b.1 == c.1); - (!straight).then_some(b) - }) - .collect() + if n < 4 { + return pts.to_vec(); + } + let d2 = |a: (f64, f64), b: (f64, f64)| (a.0 - b.0).powi(2) + (a.1 - b.1).powi(2); + let Some(&first) = pts.first() else { return vec![] }; + // Split at the first point and the point farthest from it: two open runs. + let far = (1..n).max_by(|&i, &j| d2(first, pts[i]).total_cmp(&d2(first, pts[j]))).unwrap_or(n / 2); + let mut keep = vec![false; n]; + keep[0] = true; + keep[far] = true; + let mut todo = vec![(0, far), (far, n)]; + while let Some((a, b)) = todo.pop() { + if b <= a + 1 { + continue; + } + let (p, q) = (pts[a], pts[b % n]); + let (ux, uy) = (q.0 - p.0, q.1 - p.1); + let len = ux.hypot(uy); + let dist = |r: (f64, f64)| if len > 0.0 { ((r.0 - p.0) * uy - (r.1 - p.1) * ux).abs() / len } else { d2(p, r).sqrt() }; + let Some((m, dm)) = (a + 1..b).map(|i| (i, dist(pts[i]))).max_by(|x, y| x.1.total_cmp(&y.1)) else { continue }; + if dm > tol { + keep[m] = true; + todo.push((a, m)); + todo.push((m, b)); + } + } + pts.iter().zip(&keep).filter(|(_, k)| **k).map(|(p, _)| *p).collect() } fn note(warnings: &mut Vec, w: &str) { diff --git a/crates/engine/src/tests/legacy_ai.rs b/crates/engine/src/tests/legacy_ai.rs index c4722c9c1..9a9aafcba 100644 --- a/crates/engine/src/tests/legacy_ai.rs +++ b/crates/engine/src/tests/legacy_ai.rs @@ -368,6 +368,54 @@ fn a_transparent_image_is_flattened_with_the_art_under_it_which_stays() { assert_eq!(colour_at(page, 10.0, 20.0), [255, 0, 0], "the square outside the image"); } +/// A `size`×`size` PNG of a black disk of radius `r` pixels with an anti-aliased edge, clear +/// around it, 72 ppi. +fn disk(size: u32, r: f64) -> Vec { + let c = f64::from(size) / 2.0; + let img = image::RgbaImage::from_fn(size, size, |x, y| { + let d = (f64::from(x) + 0.5 - c).hypot(f64::from(y) + 0.5 - c); + image::Rgba([0, 0, 0, ((r + 0.5 - d).clamp(0.0, 1.0) * 255.0).round() as u8]) + }); + let mut out = vec![]; + img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap(); + out +} + +/// #5264: the clip around a flattened image follows its edge smoothly: a disk's clip is a ring of +/// sloped segments close to the disk's edge, not a staircase of horizontal and vertical steps. +#[test] +fn a_flattened_images_clip_follows_its_edge_smoothly() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 180, "height": 80})).unwrap(); + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + let p = json!({"name": "disk.png", "dataBase64": vectorcraft_format::base64_encode(&disk(64, 25.0)), "at": [100, 50]}); + s.execute("file.place", &p).unwrap(); + let r = s.execute("file.saveCopy", &json!({"format": "ai", "aiVersion": "7"})).unwrap(); + s.execute("document.open", &json!({"name": "t.ai", "dataBase64": r["dataBase64"]})).unwrap(); + let d = &s.doc().unwrap().doc; + let mut clips = vec![]; + d.walk(|n| { + if let (vectorcraft_doc::NodeKind::Path { clipping: true, .. }, Some(pd)) = (&n.kind, n.path_data()) { + clips.push(pd.clone()); + } + }); + assert_eq!(clips.len(), 1, "{clips:?}"); + let sub = &clips[0].subpaths; + assert_eq!(sub.len(), 1); + let pts: Vec<_> = sub[0].anchors.iter().map(|a| a.p).collect(); + assert!(pts.len() >= 16, "{pts:?}"); + // Its centre: the file's page may sit elsewhere than the artboard. + let n = pts.len() as f64; + let (cx, cy) = (pts.iter().map(|p| p.x).sum::() / n, pts.iter().map(|p| p.y).sum::() / n); + for p in &pts { + let r = (p.x - cx).hypot(p.y - cy); + assert!((24.5..=27.5).contains(&r), "an anchor {r} pt from the centre: {pts:?}"); + } + let sloped = pts.iter().zip(pts.iter().cycle().skip(1)).filter(|(a, b)| (a.x - b.x).abs() > 0.1 && (a.y - b.y).abs() > 0.1).count(); + assert!(sloped * 2 >= pts.len(), "{sloped} of {} segments are sloped: {pts:?}", pts.len()); +} + /// How many of reply `r`'s warnings say that an image couldn't be decoded, as raster exports do. fn undecoded_notes(r: &Value) -> usize { warnings(r).iter().filter(|w| *w == "an image couldn't be decoded and is missing from the picture").count() diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 22c13bdbb..15e3fb9c4 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 (Paste: cells copied from a spreadsheet paste as a table of point type over gridlines, #5296; 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 (legacy `.ai` export: a flattened transparent image's clip follows its edge smoothly, #5264; 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 @@ -90,7 +90,7 @@ private editing data. VectorCraft: the options dialog's artboard picker (thumbnails to check, default all) is a file of its own, `{name}-{artboard}.ai` when there are several. It has no transparency: objects are written opaque, and images with see-through pixels, opacity or a blending mode are flattened (an opaque image of themselves and the art - under them, clipped to where they show; that art stays) or omitted (Transparent Images, + under them, clipped to where they show, along a smooth outline traced at the image's own resolution; that art stays) or omitted (Transparent Images, `transparentImages: "flatten"|"omit"`). Type is written as editable text objects (point type, area type and type on a path, with their fonts' PostScript names, sizes, colours, alignment, indents, leading, tracking and scaling), which the reading app sets in those fonts; characters outside Windows-1252, vertical type, ruby, inline @@ -193,6 +193,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 | Legacy `.ai` (version 7) export: the clipping path around a flattened transparent image followed 2 pt cells, a staircase that was visibly jagged at the image's resolution. It now follows the image's own pixels (in blocks only past 4 million pixels), grown by one pixel so the whole anti-aliased edge is inside, and runs through the middle of each pixel edge with Douglas–Peucker simplification, so slopes and curves are sloped lines instead of steps (#5264). Version 7 images have no alpha channel (§8.1 of the published format specification), so the image itself is still opaque. Test `legacy_ai::a_flattened_images_clip_follows_its_edge_smoothly` (crate `vectorcraft-engine`); scores unchanged | | 2026-11-06 | minor | Paste: cells copied from Excel, Google Sheets, LibreOffice Calc or Numbers (tab-separated text, quoted cells too) paste as a group of point type, one object per cell in columns and rows, over 0.5 pt gridlines; other text pastes as before; scores and estimates unchanged (#5296) | | 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 |