diff --git a/crates/engine/src/cmd/fileio/tests_eps.rs b/crates/engine/src/cmd/fileio/tests_eps.rs index af918d33e..8e2c1d429 100644 --- a/crates/engine/src/cmd/fileio/tests_eps.rs +++ b/crates/engine/src/cmd/fileio/tests_eps.rs @@ -170,6 +170,28 @@ fn transparency_is_flattened_with_the_flattener_preset() { assert!(e.contains("preset"), "{e}"); } +/// A picture cropped by a clipping path, in a clip group at 80%, over a square that only the +/// hidden part of the picture covers: the file prints the square, and its image is as large as +/// the crop, with the default preset (a rectangle of image) and with High Resolution (an image +/// clipped to its regions). +#[test] +fn flattening_cropped_art_keeps_the_art_under_its_hidden_part() { + use super::tests_pdfx::{Art, CROP, cropped, drawn, lost, shown_image_sizes}; + let _g = crate::tests_colormgmt::GLOBAL.lock().unwrap_or_else(|e| e.into_inner()); + for preset in ["medium", "high"] { + let (mut s, _) = cropped("rgb", 400.0, Art::Image { see_through: false }); + s.execute("transparency.set", &json!({"opacity": 80})).unwrap(); + let canvas = drawn(&s.doc().unwrap().doc); + let opts = json!({"artboards": [0], "flattenerPreset": preset, "previewFormat": "none", "thumbnails": false}); + let r = s.execute("document.exportEps", &opts).unwrap(); + // What the file prints, not the document it carries. + let back = vectorcraft_eps::import_with(&b64(&r), false).unwrap().document; + assert_eq!(lost(&canvas, &drawn(&back)), 0, "{preset}: art went missing"); + let sizes = shown_image_sizes(&back); + assert!(!sizes.is_empty() && sizes.iter().all(|(w, h)| *w <= CROP[2] + 1.0 && *h <= CROP[3] + 1.0), "{preset}: the images show {sizes:?}"); + } +} + #[test] fn overprints_are_kept_or_discarded() { let mut s = session(100.0, 100.0, 1); diff --git a/crates/engine/src/cmd/fileio/tests_pdfx.rs b/crates/engine/src/cmd/fileio/tests_pdfx.rs index abd1bbe4a..4063e090d 100644 --- a/crates/engine/src/cmd/fileio/tests_pdfx.rs +++ b/crates/engine/src/cmd/fileio/tests_pdfx.rs @@ -186,3 +186,318 @@ fn pdfx_1a_flattening_keeps_the_inks_behind_an_image_in_a_cmyk_document() { let ink = image_inks_at(&back, vectorcraft_geom::Point::new(88.0, 50.0)).expect("a CMYK image behind the see-through pixels"); assert!(ink.iter().zip(teal).all(|(a, b)| (a - b).abs() <= 1.5 / 255.0), "{ink:?} is not {teal:?}"); } + +fn run(s: &mut Session, id: &str, p: Value) -> Value { + s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}")) +} + +/// The art a clip group crops in [`cropped`]. +#[derive(Clone, Copy)] +pub(super) enum Art { + /// A 240 × 160 image, with see-through pixels or without. + Image { see_through: bool }, + /// A rectangle with a gradient fill. + Gradient, + /// A rectangle with a drop shadow. + Shadow, +} + +/// Where [`cropped`] puts the cropped art, [x, y, width, height]. +const FRAME: [f64; 4] = [40.0, 40.0, 240.0, 160.0]; +/// The part of [`FRAME`] the clipping path of [`cropped`] shows. +pub(super) const CROP: [f64; 4] = [50.0, 50.0, 80.0, 60.0]; + +/// A `mode` document whose first artboard is `width` wide, with a dark square and, over it, `art` +/// filling [`FRAME`] in a clip group that shows [`CROP`] of it. The square lies inside the frame +/// and outside the crop: only the hidden part of the art covers it. → the session with the clip +/// group selected, and the group's id. +pub(super) fn cropped(mode: &str, width: f64, art: Art) -> (Session, u64) { + cropped_by(mode, width, art, false) +} + +/// [`cropped`], with the art and its clipping path in a clipping layer above the square's layer +/// when `layer`. → the session and the clip group's or the layer's id. +fn cropped_by(mode: &str, width: f64, art: Art, layer: bool) -> (Session, u64) { + let [x, y, w, h] = FRAME; + let mut s = Session::new(); + run(&mut s, "file.new", json!({"width": width, "height": 300, "colorMode": mode})); + run(&mut s, "shape.rectangle", json!({"x": 180, "y": 90, "width": 60, "height": 60})); + run(&mut s, "paint.setFill", json!({"color": "#203040"})); + run(&mut s, "paint.setStroke", json!({"none": true})); + let layer = layer.then(|| run(&mut s, "layer.new", json!({}))["id"].as_u64().unwrap()); + let art = match art { + Art::Image { see_through } => { + let mut png = vec![]; + image::RgbaImage::from_fn(240, 160, |x, y| { + image::Rgba([200, (x % 256) as u8, (y % 256) as u8, if see_through && x < 60 { 128 } else { 255 }]) + }) + .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) + .unwrap(); + let placed = + run(&mut s, "file.place", json!({"name": "photo.png", "dataBase64": vectorcraft_format::base64_encode(&png), "rect": FRAME})); + placed["ids"][0].clone() + } + Art::Gradient | Art::Shadow => { + let id = run(&mut s, "shape.rectangle", json!({"x": x, "y": y, "width": w, "height": h}))["id"].clone(); + run(&mut s, "paint.setStroke", json!({"none": true})); + if matches!(art, Art::Gradient) { + let stops = json!([{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#0000ff"}]); + run(&mut s, "paint.setFill", json!({"gradient": {"kind": "linear", "stops": stops}})); + } else { + run(&mut s, "paint.setFill", json!({"color": "#c04020"})); + run(&mut s, "effect.apply", json!({"effect": "stylize.dropShadow", "ids": [id]})); + } + id + } + }; + let [x, y, w, h] = CROP; + let clip = run(&mut s, "shape.rectangle", json!({"x": x, "y": y, "width": w, "height": h}))["id"].clone(); + if let Some(layer) = layer { + run(&mut s, "layer.clippingMask.toggle", json!({"id": layer})); + run(&mut s, "select.set", json!({"ids": [art]})); + return (s, layer); + } + run(&mut s, "select.set", json!({"ids": [art, clip]})); + let group = run(&mut s, "object.clippingMask.make", json!({}))["id"].as_u64().unwrap(); + (s, group) +} + +/// `document.exportPdf` with `p`, read back. +fn read_back(s: &mut Session, p: &Value) -> vectorcraft_doc::Document { + let v = run(s, "document.exportPdf", p.clone()); + vectorcraft_pdf::import(&vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap()).unwrap() +} + +/// `doc`'s artboards drawn on white at 72 ppi. +pub(super) fn drawn(doc: &vectorcraft_doc::Document) -> vectorcraft_render::Rendered { + let pages = doc.artboards.iter().map(|a| a.rect).reduce(|a, b| a.union(b)).unwrap(); + vectorcraft_render::Renderer::new().render_region(doc, pages, 1.0, true) +} + +/// How many pixels show art in `reference` and are blank in `variant`. +pub(super) fn lost(reference: &vectorcraft_render::Rendered, variant: &vectorcraft_render::Rendered) -> usize { + assert_eq!((reference.width, reference.height), (variant.width, variant.height)); + let (reference, variant) = (reference.pixels.as_chunks::<4>().0, variant.pixels.as_chunks::<4>().0); + reference.iter().zip(variant).filter(|(r, v)| r[..3].iter().any(|c| *c < 200) && v[..3].iter().all(|c| *c >= 250)).count() +} + +/// The width and height of what each of `doc`'s images shows: its frame inside the clipping +/// paths around it. +pub(super) fn shown_image_sizes(doc: &vectorcraft_doc::Document) -> Vec<(f64, f64)> { + use vectorcraft_geom::Rect; + fn visit(n: &vectorcraft_doc::Node, clip: Option, out: &mut Vec<(f64, f64)>) { + let within = |r: Rect| clip.map_or(r, |c| c.intersect(r)); + if let vectorcraft_doc::NodeKind::Image(im) = &n.kind { + let frame = im.xf.transform_rect_bbox(Rect::new(0.0, 0.0, im.width as f64, im.height as f64)); + let shown = within(frame); + out.push((shown.width(), shown.height())); + } + let clip = if n.clips() { n.visual_bounds().map(within) } else { clip }; + for c in n.children().into_iter().flatten() { + visit(c, clip, out); + } + } + let mut out = vec![]; + for l in &doc.layers { + visit(l, None, &mut out); + } + out +} + +/// Checks that the PDF/X-1a, PDF/X-3 and PDF 1.3 files of `s` (with `extra` options) show all the +/// art the file with transparency shows, and that no image in them shows more than [`CROP`]. Adds +/// what is wrong to `wrong`. +fn check_flat_files(s: &mut Session, extra: &Value, what: &str, wrong: &mut Vec) { + let reference = drawn(&read_back(s, extra)); + for flat in [json!({"standard": "pdfX1a"}), json!({"standard": "pdfX3"}), json!({"compatibility": "1.3", "preserveEditing": false})] { + let mut p = flat.clone(); + super::pdf::merge(&mut p, extra); + let back = read_back(s, &p); + let lost = lost(&reference, &drawn(&back)); + if lost > 0 { + wrong.push(format!("{what}, {flat}: {lost} pixels of art went missing")); + } + let sizes = shown_image_sizes(&back); + // An image reaches a pixel past its regions, and a group rasterized whole a point past + // its visible bounds. + if sizes.is_empty() || sizes.iter().any(|(w, h)| *w > CROP[2] + 3.0 || *h > CROP[3] + 3.0) { + wrong.push(format!("{what}, {flat}: the images show {sizes:?}")); + } + } +} + +/// Art cropped by a clipping path, in a PDF file without transparency: the part of the art the +/// clipping path hides is not rasterized, so art that lies under the hidden part alone still +/// shows. Each case is one way the cropped art comes to be flattened. +#[test] +fn flattening_cropped_art_for_pdfx_keeps_the_art_under_its_hidden_part() { + let _g = crate::tests_colormgmt::GLOBAL.lock().unwrap_or_else(|e| e.into_inner()); + let opaque = Art::Image { see_through: false }; + type Change = fn(&mut Session, u64); + let cases: [(&str, Art, Change); 9] = [ + ("an image with see-through pixels", Art::Image { see_through: true }, |_, _| {}), + ("a clip group with opacity", opaque, |s, _| { + run(s, "transparency.set", json!({"opacity": 80})); + }), + ("a clip group with a blend mode", opaque, |s, _| { + run(s, "transparency.set", json!({"blendMode": "multiply"})); + }), + ("a see-through object over the crop", opaque, |s, _| { + run(s, "shape.rectangle", json!({"x": 60, "y": 60, "width": 40, "height": 30})); + run(s, "paint.setFill", json!({"color": "#ffffff"})); + run(s, "transparency.set", json!({"opacity": 60})); + }), + ("a gradient", Art::Gradient, |s, _| { + run(s, "transparency.set", json!({"opacity": 80})); + }), + ("a raster effect", Art::Shadow, |_, _| {}), + ("a clip group inside a clip group", Art::Image { see_through: true }, |s, group| { + let outer = run(s, "shape.rectangle", json!({"x": 45, "y": 45, "width": 85, "height": 65}))["id"].clone(); + run(s, "select.set", json!({"ids": [group, outer]})); + run(s, "object.clippingMask.make", json!({})); + }), + ("a clip group inside a group with opacity", opaque, |s, _| { + run(s, "object.group", json!({})); + run(s, "transparency.set", json!({"opacity": 80})); + }), + ("an even-odd compound clipping path", opaque, |s, group| { + run(s, "transparency.set", json!({"opacity": 80})); + let doc = s.doc_mut().unwrap(); + let mut d = (*doc.doc).clone(); + let clip = d.node(vectorcraft_doc::NodeId(group)).unwrap().children().unwrap()[0].id; + let mut ring = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(50.0, 50.0, 130.0, 110.0)); + ring.subpaths.extend(vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(70.0, 65.0, 110.0, 95.0)).subpaths); + if let vectorcraft_doc::NodeKind::Path { path, rule, .. } = &mut d.node_mut(clip).unwrap().kind { + (*path, *rule) = (ring, vectorcraft_geom::FillRule::EvenOdd); + } + doc.doc = d.into(); + }), + ]; + let mut wrong = vec![]; + for mode in ["cmyk", "rgb"] { + for (what, art, change) in cases { + let (mut s, group) = cropped(mode, 400.0, art); + change(&mut s, group); + check_flat_files(&mut s, &json!({}), &format!("{mode}: {what}"), &mut wrong); + } + } + assert!(wrong.is_empty(), "{wrong:#?}"); +} + +/// The same with the Medium and Low Resolution presets, and with a balance of 0, which rasterizes +/// the whole group of the cropped art over the group's visible bounds. +#[test] +fn flattening_cropped_art_for_pdfx_keeps_the_art_under_its_hidden_part_with_each_preset() { + let _g = crate::tests_colormgmt::GLOBAL.lock().unwrap_or_else(|e| e.into_inner()); + let mut wrong = vec![]; + for extra in [json!({"flattenerPreset": "medium"}), json!({"flattenerPreset": "low"}), json!({"flattener": {"balance": 0}})] { + for (art, name) in [(Art::Image { see_through: true }, "image"), (Art::Gradient, "gradient")] { + let (mut s, _) = cropped("cmyk", 400.0, art); + run(&mut s, "transparency.set", json!({"opacity": 80})); + check_flat_files(&mut s, &extra, &format!("{name}, {extra}"), &mut wrong); + } + } + // A cropped rectangle with a solid fill, rasterized whole. + let (mut s, group) = cropped("cmyk", 400.0, Art::Gradient); + let art = s.doc().unwrap().doc.node(vectorcraft_doc::NodeId(group)).unwrap().children().unwrap()[1].id.0; + run(&mut s, "paint.setFill", json!({"ids": [art], "color": "#c04020"})); + run(&mut s, "transparency.set", json!({"ids": [group], "opacity": 80})); + check_flat_files(&mut s, &json!({"flattener": {"balance": 0}}), "solid fill, balance 0", &mut wrong); + assert!(wrong.is_empty(), "{wrong:#?}"); +} + +/// The hidden part of cropped art on one artboard lies over the next artboard: that page keeps +/// its art. +#[test] +fn flattening_cropped_art_for_pdfx_keeps_the_next_page_under_its_hidden_part() { + let _g = crate::tests_colormgmt::GLOBAL.lock().unwrap_or_else(|e| e.into_inner()); + // The crop is on the first artboard, and the square on the second. + let (mut s, _) = cropped("cmyk", 150.0, Art::Image { see_through: true }); + run(&mut s, "artboard.new", json!({"x": 170, "y": 0, "width": 230, "height": 300})); + assert_eq!(read_back(&mut s, &json!({"standard": "pdfX1a"})).artboards.len(), 2); + let mut wrong = vec![]; + check_flat_files(&mut s, &json!({}), "two artboards", &mut wrong); + assert!(wrong.is_empty(), "{wrong:#?}"); +} + +/// Art cropped by a clipping layer, over a square in the layer below: the square keeps showing, +/// also with a second square in that layer under both the crop and the hidden part, or with a +/// see-through rectangle over both in a layer above. +#[test] +fn flattening_art_of_a_clipping_layer_for_pdfx_keeps_the_art_under_its_hidden_part() { + let _g = crate::tests_colormgmt::GLOBAL.lock().unwrap_or_else(|e| e.into_inner()); + type Change = fn(&mut Session, u64); + let cases: [(&str, Change); 3] = [ + ("alone", |_, _| {}), + ("over a square under the crop's edge", |s, _| { + let below = s.doc().unwrap().doc.layers[0].id.0; + run(s, "layer.setCurrent", json!({"id": below})); + run(s, "shape.rectangle", json!({"x": 110, "y": 60, "width": 50, "height": 30})); + run(s, "paint.setFill", json!({"color": "#406020"})); + }), + ("under a see-through rectangle over the crop's edge", |s, _| { + run(s, "layer.new", json!({})); + run(s, "shape.rectangle", json!({"x": 110, "y": 60, "width": 50, "height": 30})); + run(s, "paint.setFill", json!({"color": "#406020"})); + run(s, "transparency.set", json!({"opacity": 60})); + }), + ]; + let mut wrong = vec![]; + for mode in ["cmyk", "rgb"] { + for art in [Art::Image { see_through: true }, Art::Gradient] { + for (what, change) in cases { + let (mut s, layer) = cropped_by(mode, 400.0, art, true); + run(&mut s, "transparency.set", json!({"opacity": 80})); + change(&mut s, layer); + check_flat_files(&mut s, &json!({}), &format!("{mode}: a clipping layer {what}"), &mut wrong); + } + } + } + assert!(wrong.is_empty(), "{wrong:#?}"); +} + +/// Documents in which no clip group hides art export the same bytes as they did when the +/// flattener rasterized hidden art too. +#[test] +fn flat_pdf_files_of_art_that_no_clip_group_hides_are_unchanged() { + let _g = crate::tests_colormgmt::GLOBAL.lock().unwrap_or_else(|e| e.into_inner()); + use crate::cmd::flatten::with_clips_ignored as before; + // Written at one time, so two files differ only by what they draw. + let bytes = |s: &mut Session, p: &Value| super::pdf::encode_at("document.exportPdf", &s.doc().unwrap().doc, p, Some(1_700_000_000)).unwrap().0; + let files = [ + json!({"standard": "pdfX1a"}), + json!({"standard": "pdfX3"}), + json!({"compatibility": "1.3", "preserveEditing": false}), + json!({"standard": "pdfX1a", "flattener": {"balance": 0}}), + json!({"standard": "pdfX1a", "flattenerPreset": "low"}), + ]; + // Transparency without a clip group. + let mut plain = session(); + // A clip group at 80% whose clipping path is as large as its art, over the edge of a square. + let mut fitted = Session::new(); + run(&mut fitted, "file.new", json!({"width": 400, "height": 300, "colorMode": "cmyk"})); + run(&mut fitted, "shape.rectangle", json!({"x": 180, "y": 90, "width": 160, "height": 160})); + run(&mut fitted, "paint.setFill", json!({"color": "#203040"})); + let mut png = vec![]; + image::RgbaImage::from_fn(240, 160, |x, y| image::Rgba([200, x as u8, y as u8, 255])) + .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) + .unwrap(); + let photo = + run(&mut fitted, "file.place", json!({"name": "photo.png", "dataBase64": vectorcraft_format::base64_encode(&png), "rect": FRAME}))["ids"][0] + .clone(); + let [x, y, w, h] = FRAME; + let clip = run(&mut fitted, "shape.rectangle", json!({"x": x, "y": y, "width": w, "height": h}))["id"].clone(); + run(&mut fitted, "select.set", json!({"ids": [photo, clip]})); + run(&mut fitted, "object.clippingMask.make", json!({})); + run(&mut fitted, "transparency.set", json!({"opacity": 80})); + // Cropped art that is not flattened: nothing on it is see-through. + let (mut opaque, _) = cropped("cmyk", 400.0, Art::Image { see_through: false }); + for (what, s) in [("no clip group", &mut plain), ("a clipping path as large as its art", &mut fitted), ("opaque cropped art", &mut opaque)] { + for p in &files { + assert!(bytes(s, p) == before(|| bytes(s, p)), "{what}, {p}"); + } + } + // Art that a clip group hides is what changes. + let (mut s, _) = cropped("cmyk", 400.0, Art::Image { see_through: true }); + assert!(bytes(&mut s, &files[0]) != before(|| bytes(&mut s, &files[0]))); +} diff --git a/crates/engine/src/cmd/flatten.rs b/crates/engine/src/cmd/flatten.rs index 2a12eb9c5..f950f8c1d 100644 --- a/crates/engine/src/cmd/flatten.rs +++ b/crates/engine/src/cmd/flatten.rs @@ -49,9 +49,9 @@ mod presets; mod report; pub(crate) use analysis::{outlined_text, see_through_images}; -#[cfg(test)] -pub(crate) use apply::with_every_region; pub(crate) use apply::{expand_transparent_tile_images, flatten_document, flatten_expanded, has_transparent_tile_images}; +#[cfg(test)] +pub(crate) use apply::{with_clips_ignored, with_every_region}; pub use commands::specs; pub use options::FlattenOptions; pub use presets::{FlattenerPreset, PRESET_EXTS, PRESET_FORMAT}; diff --git a/crates/engine/src/cmd/flatten/apply.rs b/crates/engine/src/cmd/flatten/apply.rs index c70f4dca6..c9459ed76 100644 --- a/crates/engine/src/cmd/flatten/apply.rs +++ b/crates/engine/src/cmd/flatten/apply.rs @@ -107,7 +107,9 @@ pub(super) fn plan(src: &Document, roots: &[NodeId], o: &FlattenOptions, render: let art: Vec = baked.layers[0].children().into_iter().flatten().map(|a| (**a).clone()).collect(); let mut sc = Scratch { brushes: vectorcraft_brush::library(&baked), doc: baked.clone() }; let plain: Vec = art.iter().map(|n| sc.plain(n)).collect(); - let reaches: Vec> = plain.iter().map(reach).collect(); + // Each object as its clip groups and clipping layers show it. + let clipped: Vec = plain.iter().zip(roots).map(|(n, id)| clipped_by_ancestors(src, *id, n)).collect(); + let reaches: Vec> = plain.iter().zip(&clipped).map(|(n, c)| reach(n).zip(reach(c)).map(|(a, b)| a.intersect(b))).collect(); let see_through: Vec = plain.iter().map(|n| o.shows_transparency(&baked, n) || o.draws_see_through_image(n)).collect(); let cmyk = src.color_mode == ColorMode::Cmyk; let mut out = Plan { flat: vec![], kept: vec![], transparent: roots.iter().zip(&see_through).filter(|(_, t)| **t).map(|(id, _)| *id).collect() }; @@ -117,7 +119,7 @@ pub(super) fn plan(src: &Document, roots: &[NodeId], o: &FlattenOptions, render: vectorcraft_doc::cancel::check()?; let ids: Vec = g.iter().map(|&i| roots[i]).collect(); let group = g.iter().any(|&i| see_through[i]).then(|| { - let plain: Vec<&Node> = g.iter().map(|&i| &plain[i]).collect(); + let plain: Vec<&Node> = g.iter().map(|&i| &clipped[i]).collect(); flatten_group(&plain, o, cmyk) }); let Some((regions, area)) = group.flatten() else { @@ -126,7 +128,9 @@ pub(super) fn plan(src: &Document, roots: &[NodeId], o: &FlattenOptions, render: }; let raster = match area.as_ref().filter(|_| render) { Some(a) => { - let art: Vec = g.iter().map(|&i| art[i].clone()).collect(); + // Drawn as planned: the image can land outside the clip groups and clipping layers + // around its art. + let art: Vec = g.iter().map(|&i| clipped_by_ancestors(src, roots[i], &art[i])).collect(); Some(render_raster(&baked, &art, a.rect, a.ppi, a.clip.clone(), o)?) } None => None, @@ -140,6 +144,39 @@ pub(super) fn plan(src: &Document, roots: &[NodeId], o: &FlattenOptions, render: Ok(out) } +/// `plain` (object `id` of `src` as plain art) inside a clip group for each clip group and +/// clipping layer around `id`, innermost first, each clipped by that group's or layer's clipping +/// path. `plain` itself when nothing around it clips. +fn clipped_by_ancestors(src: &Document, id: NodeId, plain: &Node) -> Node { + let mut out = plain.clone(); + if !clips_count() { + return out; + } + let ancestry = src.ancestry(id).unwrap_or_default(); + for pair in ancestry.windows(2).rev() { + let Some(above) = src.node(pair[0]).filter(|n| n.clips()) else { continue }; + let Some(clip) = above.children().and_then(|c| c.first()) else { continue }; + // The clipping path itself is not clipped art. + if clip.id == pair[1] { + continue; + } + // Its outline, unpainted: the group or layer around the object still paints the path. + // Nothing to clip by hides the clipped art, as in the compositor. + let Some((outline, rule)) = effects::clip_outline(clip) else { + out.visible = false; + return out; + }; + let mut path = Node::path(clip.id, PathData::from_bezpath(&outline), Appearance::default()); + if let NodeKind::Path { rule: r, .. } = &mut path.kind { + *r = rule; + } + let mut g = Node::group(above.id, vec![Arc::new(path), Arc::new(out)]); + g.set_clips(true); + out = g; + } + out +} + /// The regions and the image area replacing one group of overlapping objects (`plain`: the art as /// composited; `cmyk`: in a CMYK document); `None` when it paints nothing. fn flatten_group(plain: &[&Node], o: &FlattenOptions, cmyk: bool) -> Option<(Regions, Option)> { @@ -163,8 +200,10 @@ fn flatten_group(plain: &[&Node], o: &FlattenOptions, cmyk: bool) -> Option<(Reg let mut complex: Vec = vec![]; let mut gradients_only = !all; for (k, r) in regions.iter().enumerate() { - if all || r.sources.iter().any(|&i| b.kinds[i] != Kind::Vector) { - gradients_only &= r.sources.iter().all(|&i| b.kinds[i] == Kind::Gradient); + // Art outside the clipping path of its clip group draws nothing there. + let shown = |i: &&usize| b.kinds[**i] == Kind::Vector || !(clips_count() && b.hidden(**i, &r.sources)); + if all || r.sources.iter().filter(shown).any(|&i| b.kinds[i] != Kind::Vector) { + gradients_only &= r.sources.iter().filter(shown).all(|&i| b.kinds[i] == Kind::Gradient); complex.push(k); } else if let Some(fill) = region_fill(&elems, &r.sources, o, cmyk) { vector.push((k, fill)); @@ -227,6 +266,35 @@ pub(crate) fn with_every_region(f: impl FnOnce() -> T) -> T { out } +#[cfg(test)] +thread_local! { + /// Groups flattened on this thread rasterize art where its clip group or clipping layer hides + /// it too ([`with_clips_ignored`]). + static CLIPS_IGNORED: std::cell::Cell = const { std::cell::Cell::new(false) }; +} + +/// Whether a region outside the clipping path of a clip group or clipping layer leaves out the +/// art it hides there. +#[cfg(test)] +fn clips_count() -> bool { + !CLIPS_IGNORED.with(std::cell::Cell::get) +} + +#[cfg(not(test))] +fn clips_count() -> bool { + true +} + +/// `f` with every group it flattens rasterizing art where its clip group or clipping layer hides +/// it too. +#[cfg(test)] +pub(crate) fn with_clips_ignored(f: impl FnOnce() -> T) -> T { + CLIPS_IGNORED.with(|e| e.set(true)); + let out = f(); + CLIPS_IGNORED.with(|e| e.set(false)); + out +} + /// The flat colour of a region covered by `sources` (CMYK in a `cmyk` document); `None` where /// nothing paints it. fn region_fill(elems: &[Elem], sources: &[usize], o: &FlattenOptions, cmyk: bool) -> Option { @@ -424,7 +492,7 @@ pub(super) fn apply(d: &mut Document, sel: &mut vectorcraft_doc::Selection, plan let one_pass = separate(d, &plan.flat.iter().flat_map(|f| f.roots.iter().copied()).collect::>()); for f in plan.flat { // A group always has objects. - let Some(&top) = f.roots.last() else { continue }; + let Some(top) = place_of(d, &f.roots) else { continue }; let mut children = vec![]; // The image goes under the regions, reaching a pixel past its own: their edges then fall on // matching pixels instead of on what is behind the group. @@ -494,6 +562,23 @@ pub(super) fn apply(d: &mut Document, sel: &mut vectorcraft_doc::Selection, plan Ok((ids, vector, rasterized)) } +/// The object of `roots` (in paint order) whose place their flattened group takes: the topmost one +/// that no clip group or clipping layer clips unless it clips every object of `roots`, else the +/// topmost one. +fn place_of(d: &Document, roots: &[NodeId]) -> Option { + let top = roots.last().copied(); + if !clips_count() || roots.len() < 2 { + return top; + } + let clips = |id: NodeId| -> Vec { + let ancestry = d.ancestry(id).unwrap_or_default(); + ancestry.iter().rev().skip(1).filter(|a| d.node(**a).is_some_and(Node::clips)).copied().collect() + }; + let all: Vec> = roots.iter().map(|r| clips(*r)).collect(); + let shared = |mine: &Vec| mine.iter().all(|c| all.iter().all(|theirs| theirs.contains(c))); + roots.iter().zip(&all).rev().find(|(_, mine)| shared(mine)).map(|(r, _)| *r).or(top) +} + /// An image of `r` (in a clip group when it is clipped). fn raster_node(d: &mut Document, r: Raster) -> Node { let key = unique_key(d, "flattened"); diff --git a/crates/engine/src/cmd/flatten/commands.rs b/crates/engine/src/cmd/flatten/commands.rs index a2edde1f0..d528e40d8 100644 --- a/crates/engine/src/cmd/flatten/commands.rs +++ b/crates/engine/src/cmd/flatten/commands.rs @@ -10,7 +10,7 @@ pub fn specs() -> Vec { "Flatten Transparency…", ["Object"], None, - "{preset?: \"high\"|\"medium\"|\"low\" or a saved preset's name (see flattener.presets.list; default medium), balance?: 0..100 (raster/vector balance; 0 rasterizes everything), lineArtPpi?: 1..2400, gradientPpi?: 1..2400 (areas only gradients and meshes reach), textToOutlines?, strokesToOutlines?, clipComplexRegions? (clip images to the region outlines, else rectangles), antiAlias?, preserveAlpha? (composite over nothing instead of white), preserveOverprints? (areas showing one paint keep its colour and overprint; false clears overprints), options?: {the same keys}, ids?|id?} overlapping transparent objects become one group of flat-colour regions (CMYK colours composited ink by ink in CMYK documents), plus an image where gradients, patterns, images, masks or raster effects reach; objects without transparency stay; a frame of threaded text that is outlined or flattened leaves its thread with the text it shows → {ids, rasterized: images made, vector: regions made, options, warning? (an image in the art couldn't be decoded, as object.rasterize says)}", + "{preset?: \"high\"|\"medium\"|\"low\" or a saved preset's name (see flattener.presets.list; default medium), balance?: 0..100 (raster/vector balance; 0 rasterizes everything), lineArtPpi?: 1..2400, gradientPpi?: 1..2400 (areas only gradients and meshes reach), textToOutlines?, strokesToOutlines?, clipComplexRegions? (clip images to the region outlines, else rectangles), antiAlias?, preserveAlpha? (composite over nothing instead of white), preserveOverprints? (areas showing one paint keep its colour and overprint; false clears overprints), options?: {the same keys}, ids?|id?} overlapping transparent objects become one group of flat-colour regions (CMYK colours composited ink by ink in CMYK documents), plus an image where gradients, patterns, images, masks or raster effects reach (only where a clip group's or clipping layer's clipping path shows them); objects without transparency stay; a frame of threaded text that is outlined or flattened leaves its thread with the text it shows → {ids, rasterized: images made, vector: regions made, options, warning? (an image in the art couldn't be decoded, as object.rasterize says)}", has_doc, flatten ), diff --git a/crates/engine/src/cmd/flatten/compose.rs b/crates/engine/src/cmd/flatten/compose.rs index 059890534..fef23f86a 100644 --- a/crates/engine/src/cmd/flatten/compose.rs +++ b/crates/engine/src/cmd/flatten/compose.rs @@ -53,6 +53,11 @@ enum Body { pub(super) struct Builder { pub(super) shapes: Vec, pub(super) kinds: Vec, + /// Per shape, the clipping path of the innermost clip group it is in (a clipping path's own + /// entry is the clip group around its group). + pub(super) clip_of: Vec>, + /// The clipping path of the clip group being built. + pub(super) clip: Option, /// Building a CMYK document's art. pub(super) cmyk: bool, } @@ -72,9 +77,24 @@ impl Builder { path.bounds()?; self.shapes.push(po::Shape::new(path, rule, self.shapes.len() as u64)); self.kinds.push(kind); + self.clip_of.push(self.clip); Some(self.shapes.len() - 1) } + /// Whether a clip group hides shape `shape` in the region `sources` cover (ascending): the + /// region lies outside one of the clipping paths around the shape. + pub(super) fn hidden(&self, shape: usize, sources: &[usize]) -> bool { + let around = |shape: usize| self.clip_of.get(shape).copied().flatten(); + let mut clip = around(shape); + while let Some(c) = clip { + if sources.binary_search(&c).is_err() { + return true; + } + clip = around(c); + } + false + } + pub(super) fn elem(&mut self, n: &Node) -> Option { if !n.visible { return None; @@ -93,7 +113,11 @@ impl Builder { } else { (None, &children[..]) }; - Body::Group { clip, children: rest.iter().filter_map(|c| self.elem(c)).collect() } + let outer = self.clip; + self.clip = clip.or(outer); + let children = rest.iter().filter_map(|c| self.elem(c)).collect(); + self.clip = outer; + Body::Group { clip, children } } NodeKind::Path { guide: true, .. } => return None, NodeKind::Path { .. } | NodeKind::Compound { .. } => { diff --git a/docs/mcp.md b/docs/mcp.md index bc2048393..face50368 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3061,6 +3061,8 @@ one undo step. The targets split into groups of overlapping objects; groups with Each other group becomes one group of atomic regions: paths filled with the flat colour the art showed there (over white, which blend modes do not read; over nothing, keeping alpha, with `preserveAlpha: true`), plus one image where gradients, patterns, images, opacity masks or raster effects reach (clipped to those regions with `clipComplexRegions`, a rectangle without). +The image leaves out the part of such art that the clipping path of a clip group or clipping layer hides, so art under +the hidden part of a cropped picture stays as it is. `preset` is `high`, `medium` (the default) or `low`; option keys (`balance` 0–100, `lineArtPpi`, `gradientPpi`, `textToOutlines`, `strokesToOutlines`, `clipComplexRegions`, `antiAlias`, `preserveAlpha`, `preserveOverprints`) override it, at the top level or in `options`. `balance: 0` rasterizes everything; lower balances rasterize groups diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 6bf45e9c8..5e291ded5 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1413,6 +1413,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur In a `.ai` opened from its editing data, Bas Relief in an art style takes its Detail, Smoothness and Light direction from the style's settings block, and an object other than type with it opens as itself with the live effect. A settings block that VectorCraft can't read (malformed, another effect's, a value out of range or a key it doesn't know) keeps the object's drawn look (part of #637; [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646); `crates/eps/src/import/ai/style/tests.rs`, `crates/eps/src/import/ai/tests.rs`). PDF export and `.ai` saves write a page with a side longer than 14,400 pt with a `UserUnit` (PDF 1.6), its longer side divided by 14,400 and rounded up to the next thousandth, so that no side is longer than 14,400 units (ISO 32000-1, Annex C.2); a PDF 1.5 file with such a page is written as PDF 1.6 with PDF 1.5's encryption, PDF/X files and PDF 1.3 and 1.4 files write it in points (in PDF 1.3 and 1.4, a page past 32,767 pt that needs a real number past 32,767 is refused with an error naming it), a page whose short side is 3 pt or longer but would be under 3 units at that `UserUnit` gets a smaller one that keeps the short side at 3 units, and the export's warnings give each such page's size and how it is written (#6382). PDF export warns when a PDF/X file has no bleed on a side where the document's bleed has one, and writes the file as asked. + PDF/X-1a, PDF/X-3 and PDF 1.3 files keep the art under the hidden part of a picture, gradient, pattern, mesh or raster effect that a clipping mask or clipping layer crops: flattening rasterizes only the part of such art that its clipping path shows, on its own page and on the next one (`crates/engine/src/cmd/fileio/tests_pdfx.rs`). Placed (linked) files in a `.ai`'s editing data come in as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (a file found next to the document, at its path, or in the folder the editing data names relative to the document's, such as `src/`, is read as the document opens, and its link takes that file's size, modification time and hash). A file with them no longer falls back to its PDF part, so its guides, sublayers and hidden objects open (#1264; `crates/eps/src/import/ai/tests.rs`, `crates/engine/src/tests/links.rs`). In a file opened that way, a gradient with stops in the middle runs its whole ramp across its shape and a gradient object no longer passes its fill to the next path (#1390); text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line, the frame made taller with a warning in the open result (#1392). Placed (linked) files in a `.ai`'s editing data come in as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (a file found next to the document or at its path is read as the document opens). A file with them no longer falls back to its PDF part, so its guides, sublayers and hidden objects open (#1264; `crates/eps/src/import/ai/tests.rs`, `crates/engine/src/tests/links.rs`). In a file opened that way, a gradient with stops in the middle runs its whole ramp across its shape and a gradient object no longer passes its fill to the next path (#1390); text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line, the frame made taller with a warning in the open result (#1392). A placed PDF or `.ai` file in a `.ai`'s editing data comes in as a placed document instead of a linked image: it shows the page and Crop to box its dictionary names, and the preview the file keeps until its file is found, then the page as vectors. A placed file in opacity-mask art keeps showing its preview (`crates/eps/src/import/ai/tests.rs`, `crates/engine/src/tests/placed_document.rs`).