mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-07 12:13:19 +00:00
Merge pull request #5953 from mockydevs/fix/legacy-ai-smooth-image-clip-5264
Legacy .ai export: a flattened transparent image's clip follows its edge smoothly
This commit is contained in:
@@ -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<Option<Flat>> {
|
||||
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<Option<Flat>> {
|
||||
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<Anchor> = 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<Node> {
|
||||
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<bool> {
|
||||
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<String>, w: &str) {
|
||||
|
||||
@@ -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<u8> {
|
||||
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::<f64>() / n, pts.iter().map(|p| p.y).sum::<f64>() / 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()
|
||||
|
||||
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user