From c6e29fa22780d339c0c9e1da405e89bd420a0831 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 10 Nov 2026 17:40:14 -0800 Subject: [PATCH 1/2] PDF export: PDF/X-1a, X-3 and 1.3 keep the art under the hidden part of cropped art Flattening for PDF/X-1a, PDF/X-3, PDF 1.3 and EPS rasterized a picture, gradient, pattern, mesh or raster effect over its whole frame when a clipping mask cropped it. The hidden part came out as opaque white and covered the art under it, on the same page and on the next one. - Flattening rasterizes clipped art only where its clipping path shows it, nested clip groups included. - Art in a clipping layer is flattened as the layer's clipping path shows it. A flattened group that also holds art from outside the layer takes the place of that art, so the layer's clipping path no longer cuts it. Documents in which no clip group hides flattened art, and no clipping layer holds it, export the same bytes as before. --- crates/engine/src/cmd/fileio/tests_eps.rs | 22 ++ crates/engine/src/cmd/fileio/tests_pdfx.rs | 315 +++++++++++++++++++++ crates/engine/src/cmd/flatten.rs | 4 +- crates/engine/src/cmd/flatten/apply.rs | 97 ++++++- crates/engine/src/cmd/flatten/commands.rs | 2 +- crates/engine/src/cmd/flatten/compose.rs | 26 +- docs/mcp.md | 2 + docs/target-app-parity.md | 1 + 8 files changed, 459 insertions(+), 10 deletions(-) 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`). From 22189986a9aac6973e3a850182ea1465abd98def Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 10 Nov 2026 22:37:34 -0800 Subject: [PATCH 2/2] PDF export: flattening for PDF/X-1a, X-3 and 1.3 keeps art across clipping layers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PDF/X-1a, PDF/X-3 and PDF 1.3 files leave the layers they don't print out of the flattening. A flattened group no longer takes the place of art in a layer that doesn't print, so the art of a clipping layer over such a layer prints, and art of a layer that doesn't print stays out of the file. - A flattened group goes into a lower layer only when that layer prints, shows and is a template as the clipping layer does, the clipping layer and the groups it leaves have no opacity, blend mode or isolation, and the clipping path has no fill. Otherwise the group holds only the art inside the clipping layer, drawn over the clipping path's fill and the art below, and the art below is flattened on its own. Before, the fill covered the group, the group lost the layer's opacity, or the clipping path cut the art below. - Transparent art that a clipping path hides entirely, such as one of area type holding only a space, is removed. Before, the export failed because the art kept its transparency. Object › Flatten Transparency follows the same rules. Documents without clipping layers or layers that don't print export the same bytes as before. --- crates/engine/src/cmd/fileio/pdf.rs | 25 ++- crates/engine/src/cmd/fileio/tests_pdfx.rs | 198 ++++++++++++++++++++ crates/engine/src/cmd/flatten/apply.rs | 205 +++++++++++++++++++-- crates/engine/src/cmd/flatten/commands.rs | 2 +- docs/mcp.md | 10 +- docs/target-app-parity.md | 2 +- 6 files changed, 411 insertions(+), 31 deletions(-) diff --git a/crates/engine/src/cmd/fileio/pdf.rs b/crates/engine/src/cmd/fileio/pdf.rs index e0acc2d52..ccafc96e5 100644 --- a/crates/engine/src/cmd/fileio/pdf.rs +++ b/crates/engine/src/cmd/fileio/pdf.rs @@ -122,7 +122,7 @@ pub fn specs() -> Vec { "Export PDF", [], None, - "{path?, preset?: \"VectorCraft Default\" (built-in or saved: pdf.preset.list), artboard? | artboards?: [i…] | range?: \"1-3, 5\" (1-based; default all, one page each), standard?: none|pdfA2b|pdfX1a (CMYK, grey and spot colours only: colours and images converted to output.destination or the CMYK output intent, untagged; transparency flattened with flattenerPreset; PDF 1.3)|pdfX3 (colours ICC-tagged; transparency flattened; PDF 1.3)|pdfX4 (transparency and layers kept; colours ICC-tagged; PDF 1.6 at most) (PDF/X: a /GTS_PDFX output intent, by default the CMYK profile in effect, embedded; TrimBox, Trapped, GTS_PDFXVersion, title and dates; fonts embedded or outlined), compatibility?: 1.3 (no transparency: it is flattened with flattenerPreset; images keep no see-through pixels)|1.4|1.5|1.6|1.7 (default)|2.0 (choosing a standard sets its version: 1.7 for pdfA2b, 1.6 for pdfX4, 1.3 for pdfX1a and pdfX3, which write 1.4 as 1.3 too; pdfA2b refuses 1.3; a page with a side longer than 14,400 pt keeps its size with a UserUnit from 1.6 on: 1.5 is then written as 1.6, with 1.5's encryption; PDF/X files, 1.3 and 1.4 write it in points, and 1.3 and 1.4 (pdfX1a and pdfX3 too) refuse a page past 32,767 pt whose size or art needs a real number past 32,767; 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; a warning gives each such page's size), preserveEditing? (on in VectorCraft Default: the native document as an embedded file, which document.open restores; choosing a standard turns it off, a standard refuses it), thumbnails? (each page drawn as a /Thumb image, 106 px on its long side), fastWebView? (a linearised file: the first page shows while the rest downloads; it stays linearised when encrypted), viewAfterSaving? (the app opens the written file), createLayers? (each layer and sublayer but template layers as a PDF layer, an optional content group listed under its layer's: hidden layers off, non-printing ones /PrintState /OFF, locked ones locked; needs 1.5 or later; they reopen as layers; choosing pdfX1a or pdfX3 turns it off; they refuse it), includeNonPrinting? (keep layers whose Print option is off; left out by default unless createLayers), flattenerPreset?: \"\" (High Resolution) | high | medium | low | a saved preset (flattener.presets.list): what PDF 1.3 files flatten transparency with, flattener?: {object.flattenTransparency options over it}, compression?: {color?, gray?: {downsample: none|average|subsample|bicubic, ppi: 300, abovePpi: 450, compression: none|zip|jpeg|jpeg2000|auto, quality: minimum|low|medium|high|maximum}, mono?: {downsample, ppi: 1200, abovePpi: 1800, compression: none|ccittG3|ccittG4|zip|runLength} (black-and-white images), compressText?: true} (images above abovePpi are resampled to ppi; auto keeps JPEGs JPEG, the others lossless; JPEG needs opaque images; none, jpeg2000, CCITT and runLength are written as ZIP with a warning), marks?: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25 (trim marks and targets), offset: 6 (pt from the artboard, at least the bleed)} (printer's marks in [Registration], every plate: trim marks, registration targets mid-side, CMYK/spot/black-tint colour bars on top, page information (title, artboard, date UTC) below), bleed?: {useDocument (the document's bleed, document.setup), top, bottom, left, right} (pt; art in the bleed is kept; a PDF/X file with no bleed on a side where the document's bleed has one comes back with a warning). Each page: TrimBox = artboard, BleedBox = artboard + bleed, MediaBox = that + the marks' room (art clipped to the BleedBox), output?: {conversion: none (default)|destination (every colour into the destination profile's model, CMYK or RGB, also colours of that model in another profile)|preserveNumbers (only colours of the other model and Lab; those in the destination's model keep their numbers) (grey stays grey; images are converted too, printer's marks are not), destination: profile name (edit.colorSettings lists them; default: the document's profile for its colour mode), profiles: none (device colours)|all|destination|taggedSource (documents assigned a profile: edit.assignProfile) (ICC-based colours: CMYK with the destination or document CMYK profile, RGB as sRGB, grey with the sRGB tone curve; PDF/A, pdfX3 and pdfX4 always, whatever profiles says), outputIntent: profile name (embedded as the catalog's /GTS_PDFX output intent; a name that isn't a profile is only named, which pdfX1a and pdfX3 allow only with a registry and pdfX4 refuses; pdfX1a needs a CMYK one), outputCondition, outputConditionId (default: outputIntent), registry, trapped (/Trapped /True; /False when an output intent is written)} (PDF/A files keep their own output intent), advanced?: {fontSubsetPercent: 100 (below 100 is accepted with a warning: fonts are always embedded as subsets), outlineText: false (type as real, selectable and searchable text in embedded subset fonts with a ToUnicode map; fonts whose licence forbids embedding, or allows only whole fonts, stay outlines with a warning; true: every glyph as an outline), overprint: preserve (overprinting fills and strokes set /OP /op true, /OPM 1; white ones knock out with document.setup discardWhiteOverprint)|discard (they knock out)}, security?: {openPassword, permissionsPassword, printing: none|low|high, changes: none|pages|forms|comments|any, copy, screenReader, plaintextMetadata}, acknowledgeLoss?: bool (write over the file the document was read from although reading it left things out), format?: \"pdf\"} → {path, bytes, warnings}; no path → {dataBase64, bytes, warnings}. The options apply over the preset (null keeps its value); options accepted but not applied yet come back as warnings; a standard with a PDF version it doesn't allow and a password with a standard are refused, and so is a PDF/X file that would still break its standard (transparency the flattener left, RGB in pdfX1a, a font not embedded) or a PDF 1.3 file with transparency left. document.export {format: pdf} takes the same options. A key PDF doesn't take, and an unknown key inside one of the option objects, are refused", + "{path?, preset?: \"VectorCraft Default\" (built-in or saved: pdf.preset.list), artboard? | artboards?: [i…] | range?: \"1-3, 5\" (1-based; default all, one page each), standard?: none|pdfA2b|pdfX1a (CMYK, grey and spot colours only: colours and images converted to output.destination or the CMYK output intent, untagged; transparency flattened with flattenerPreset; PDF 1.3)|pdfX3 (colours ICC-tagged; transparency flattened; PDF 1.3)|pdfX4 (transparency and layers kept; colours ICC-tagged; PDF 1.6 at most) (PDF/X: a /GTS_PDFX output intent, by default the CMYK profile in effect, embedded; TrimBox, Trapped, GTS_PDFXVersion, title and dates; fonts embedded or outlined), compatibility?: 1.3 (no transparency: it is flattened with flattenerPreset, without the layers the file leaves out; images keep no see-through pixels)|1.4|1.5|1.6|1.7 (default)|2.0 (choosing a standard sets its version: 1.7 for pdfA2b, 1.6 for pdfX4, 1.3 for pdfX1a and pdfX3, which write 1.4 as 1.3 too; pdfA2b refuses 1.3; a page with a side longer than 14,400 pt keeps its size with a UserUnit from 1.6 on: 1.5 is then written as 1.6, with 1.5's encryption; PDF/X files, 1.3 and 1.4 write it in points, and 1.3 and 1.4 (pdfX1a and pdfX3 too) refuse a page past 32,767 pt whose size or art needs a real number past 32,767; 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; a warning gives each such page's size), preserveEditing? (on in VectorCraft Default: the native document as an embedded file, which document.open restores; choosing a standard turns it off, a standard refuses it), thumbnails? (each page drawn as a /Thumb image, 106 px on its long side), fastWebView? (a linearised file: the first page shows while the rest downloads; it stays linearised when encrypted), viewAfterSaving? (the app opens the written file), createLayers? (each layer and sublayer but template layers as a PDF layer, an optional content group listed under its layer's: hidden layers off, non-printing ones /PrintState /OFF, locked ones locked; needs 1.5 or later; they reopen as layers; choosing pdfX1a or pdfX3 turns it off; they refuse it), includeNonPrinting? (keep layers whose Print option is off; left out by default unless createLayers), flattenerPreset?: \"\" (High Resolution) | high | medium | low | a saved preset (flattener.presets.list): what PDF 1.3 files flatten transparency with, flattener?: {object.flattenTransparency options over it}, compression?: {color?, gray?: {downsample: none|average|subsample|bicubic, ppi: 300, abovePpi: 450, compression: none|zip|jpeg|jpeg2000|auto, quality: minimum|low|medium|high|maximum}, mono?: {downsample, ppi: 1200, abovePpi: 1800, compression: none|ccittG3|ccittG4|zip|runLength} (black-and-white images), compressText?: true} (images above abovePpi are resampled to ppi; auto keeps JPEGs JPEG, the others lossless; JPEG needs opaque images; none, jpeg2000, CCITT and runLength are written as ZIP with a warning), marks?: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25 (trim marks and targets), offset: 6 (pt from the artboard, at least the bleed)} (printer's marks in [Registration], every plate: trim marks, registration targets mid-side, CMYK/spot/black-tint colour bars on top, page information (title, artboard, date UTC) below), bleed?: {useDocument (the document's bleed, document.setup), top, bottom, left, right} (pt; art in the bleed is kept; a PDF/X file with no bleed on a side where the document's bleed has one comes back with a warning). Each page: TrimBox = artboard, BleedBox = artboard + bleed, MediaBox = that + the marks' room (art clipped to the BleedBox), output?: {conversion: none (default)|destination (every colour into the destination profile's model, CMYK or RGB, also colours of that model in another profile)|preserveNumbers (only colours of the other model and Lab; those in the destination's model keep their numbers) (grey stays grey; images are converted too, printer's marks are not), destination: profile name (edit.colorSettings lists them; default: the document's profile for its colour mode), profiles: none (device colours)|all|destination|taggedSource (documents assigned a profile: edit.assignProfile) (ICC-based colours: CMYK with the destination or document CMYK profile, RGB as sRGB, grey with the sRGB tone curve; PDF/A, pdfX3 and pdfX4 always, whatever profiles says), outputIntent: profile name (embedded as the catalog's /GTS_PDFX output intent; a name that isn't a profile is only named, which pdfX1a and pdfX3 allow only with a registry and pdfX4 refuses; pdfX1a needs a CMYK one), outputCondition, outputConditionId (default: outputIntent), registry, trapped (/Trapped /True; /False when an output intent is written)} (PDF/A files keep their own output intent), advanced?: {fontSubsetPercent: 100 (below 100 is accepted with a warning: fonts are always embedded as subsets), outlineText: false (type as real, selectable and searchable text in embedded subset fonts with a ToUnicode map; fonts whose licence forbids embedding, or allows only whole fonts, stay outlines with a warning; true: every glyph as an outline), overprint: preserve (overprinting fills and strokes set /OP /op true, /OPM 1; white ones knock out with document.setup discardWhiteOverprint)|discard (they knock out)}, security?: {openPassword, permissionsPassword, printing: none|low|high, changes: none|pages|forms|comments|any, copy, screenReader, plaintextMetadata}, acknowledgeLoss?: bool (write over the file the document was read from although reading it left things out), format?: \"pdf\"} → {path, bytes, warnings}; no path → {dataBase64, bytes, warnings}. The options apply over the preset (null keeps its value); options accepted but not applied yet come back as warnings; a standard with a PDF version it doesn't allow and a password with a standard are refused, and so is a PDF/X file that would still break its standard (transparency the flattener left, RGB in pdfX1a, a font not embedded) or a PDF 1.3 file with transparency left. document.export {format: pdf} takes the same options. A key PDF doesn't take, and an unknown key inside one of the option objects, are refused", has_doc, export_pdf ), @@ -360,9 +360,9 @@ fn encode_carrying_at( if set.pdf13() { vectorcraft_doc::cancel::progress(0, 0, "Flattening transparency"); } - let flat = flatten_for(cmd, doc, set, p)?; + let (flat, flattened) = flatten_for(cmd, doc, set, p)?; vectorcraft_doc::cancel::check()?; - if flat.is_some() { + if flattened { let kind = if set.standard.flattens() { set.standard.label() } else { set.compatibility.label() }; warnings.push(format!("transparency is flattened into opaque art and images ({kind} files have none): see the flattener preset")); } @@ -379,11 +379,19 @@ pub const DEFAULT_FLATTENER: &str = "High Resolution"; /// whose files have none), with `flattenerPreset` (default High Resolution) and `p`'s `flattener` /// options over it; images with see-through pixels and colorized images, including those in /// pattern tiles, count as transparency. Rasterized areas are clipped to their regions, and -/// discarded overprints are dropped. `None` when nothing changes (or the file keeps transparency). -fn flatten_for(cmd: &str, doc: &Document, set: &PdfSettings, p: &Value) -> Result> { +/// discarded overprints are dropped. Layers the pages leave out (non-printing ones, unless asked +/// for) are left out of the flattening, hidden. → the document to write (`None`: `doc` as it is) +/// and whether its transparency was flattened. +fn flatten_for(cmd: &str, doc: &Document, set: &PdfSettings, p: &Value) -> Result<(Option, bool)> { if !set.pdf13() { - return Ok(None); + return Ok((None, false)); } + let printed = if set.include_non_printing || set.create_layers { None } else { without_non_printing(&doc.layers) }; + let shown = printed.map(|layers| { + let mut d = doc.clone(); + d.layers = layers; + d + }); let name = Some(set.flattener_preset.trim()).filter(|n| !n.is_empty()).unwrap_or(DEFAULT_FLATTENER); let mut o = FlattenOptions::for_export(Some(name), p.get("flattener"), &[]).map_err(|e| bad(cmd, format!("PDF flattener: {e}")))?; o.preserve_overprints &= set.advanced.overprint == Overprint::Preserve; @@ -391,7 +399,10 @@ fn flatten_for(cmd: &str, doc: &Document, set: &PdfSettings, p: &Value) -> Resul // Without soft masks a rasterized area can't be a rectangle with see-through pixels around // its art: it is clipped to its regions (opaque inside them). o.clip_complex_regions = true; - flatten_document(doc, &o) + Ok(match flatten_document(shown.as_ref().unwrap_or(doc), &o)? { + Some(flat) => (Some(flat), true), + None => (shown, false), + }) } /// The pages `opts` export of `doc` drawn on white, [`THUMBNAIL_SIZE`] pixels on their long side: diff --git a/crates/engine/src/cmd/fileio/tests_pdfx.rs b/crates/engine/src/cmd/fileio/tests_pdfx.rs index 4063e090d..68df66bd9 100644 --- a/crates/engine/src/cmd/fileio/tests_pdfx.rs +++ b/crates/engine/src/cmd/fileio/tests_pdfx.rs @@ -281,6 +281,13 @@ pub(super) fn lost(reference: &vectorcraft_render::Rendered, variant: &vectorcra reference.iter().zip(variant).filter(|(r, v)| r[..3].iter().any(|c| *c < 200) && v[..3].iter().all(|c| *c >= 250)).count() } +/// How many pixels of `a` and `b` differ by more than 24 in a channel. +fn differ(a: &vectorcraft_render::Rendered, b: &vectorcraft_render::Rendered) -> usize { + assert_eq!((a.width, a.height), (b.width, b.height)); + let (a, b) = (a.pixels.as_chunks::<4>().0, b.pixels.as_chunks::<4>().0); + a.iter().zip(b).filter(|(p, q)| p[..3].iter().zip(&q[..3]).any(|(x, y)| x.abs_diff(*y) > 24)).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)> { @@ -501,3 +508,194 @@ fn flat_pdf_files_of_art_that_no_clip_group_hides_are_unchanged() { let (mut s, _) = cropped("cmyk", 400.0, Art::Image { see_through: true }); assert!(bytes(&mut s, &files[0]) != before(|| bytes(&mut s, &files[0]))); } + +/// The options of the PDF files without transparency. +const FLAT_FILES: [&str; 3] = [r#"{"standard": "pdfX1a"}"#, r#"{"standard": "pdfX3"}"#, r#"{"compatibility": "1.3", "preserveEditing": false}"#]; + +/// [`cropped_by`]'s clipping layer of a gradient at 80%, with a rectangle in the layer below that +/// crosses the crop's right edge. → the session and the clipping layer's id. +fn clipping_layer_over_a_rectangle(mode: &str) -> (Session, u64) { + let (mut s, layer) = cropped_by(mode, 400.0, Art::Gradient, true); + run(&mut s, "transparency.set", json!({"opacity": 80})); + let below = s.doc().unwrap().doc.layers[0].id.0; + run(&mut s, "layer.setCurrent", json!({"id": below})); + run(&mut s, "shape.rectangle", json!({"x": 100, "y": 60, "width": 120, "height": 40})); + run(&mut s, "paint.setFill", json!({"color": "#406020"})); + (s, layer) +} + +/// A clipping layer over a layer that doesn't print, and a clipping layer that doesn't print +/// over one that does: the files without transparency print what the file with transparency +/// prints, and nothing of the layer that doesn't print. +#[test] +fn flattening_for_pdfx_leaves_out_the_layers_that_do_not_print() { + let _g = crate::tests_colormgmt::GLOBAL.lock().unwrap_or_else(|e| e.into_inner()); + let mut wrong = vec![]; + for mode in ["cmyk", "rgb"] { + for clipping_layer_prints in [true, false] { + let (mut s, layer) = clipping_layer_over_a_rectangle(mode); + let below = s.doc().unwrap().doc.layers[0].id.0; + let off = if clipping_layer_prints { below } else { layer }; + run(&mut s, "layer.setProps", json!({"id": off, "printable": false})); + let reference = drawn(&read_back(&mut s, &json!({}))); + for flat in FLAT_FILES { + let back = drawn(&read_back(&mut s, &serde_json::from_str(flat).unwrap())); + let (lost, gained) = (lost(&reference, &back), lost(&back, &reference)); + if lost + gained > 0 { + wrong.push(format!("{mode}, clipping layer prints: {clipping_layer_prints}, {flat}: {lost} pixels lost, {gained} gained")); + } + } + } + } + assert!(wrong.is_empty(), "{wrong:#?}"); +} + +/// A clipping layer whose clipping path has a fill: the files without transparency show the art +/// over the fill, not the fill over the art. +#[test] +fn flattening_for_pdfx_keeps_the_art_of_a_clipping_layer_over_its_clipping_path_fill() { + let _g = crate::tests_colormgmt::GLOBAL.lock().unwrap_or_else(|e| e.into_inner()); + let mut wrong = vec![]; + for mode in ["cmyk", "rgb"] { + let (mut s, layer) = clipping_layer_over_a_rectangle(mode); + let clip = s.doc().unwrap().doc.node(vectorcraft_doc::NodeId(layer)).unwrap().children().unwrap()[0].id.0; + run(&mut s, "paint.setFill", json!({"ids": [clip], "color": "#ffdd00"})); + let reference = drawn(&read_back(&mut s, &json!({}))); + for flat in FLAT_FILES { + let back = drawn(&read_back(&mut s, &serde_json::from_str(flat).unwrap())); + // PDF/X-1a converts an RGB document's colors to CMYK. + let converted = mode == "rgb" && flat.contains("pdfX1a"); + let (lost, differ) = (lost(&reference, &back), if converted { 0 } else { differ(&reference, &back) }); + if lost > 0 || differ > 100 { + wrong.push(format!("{mode}, {flat}: {lost} pixels of art lost, {differ} pixels differ")); + } + } + } + assert!(wrong.is_empty(), "{wrong:#?}"); +} + +/// Transparent art in a clipping layer whose clipping path is area type holding only a space, +/// which hides it: the files without transparency are written, without the art. +#[test] +fn flattening_for_pdfx_removes_transparent_art_that_a_clipping_layer_hides_entirely() { + let _g = crate::tests_colormgmt::GLOBAL.lock().unwrap_or_else(|e| e.into_inner()); + let [x, y, w, h] = FRAME; + let mut s = Session::new(); + run(&mut s, "file.new", json!({"width": 400, "height": 300, "colorMode": "cmyk"})); + let layer = run(&mut s, "layer.new", json!({}))["id"].as_u64().unwrap(); + run(&mut s, "shape.rectangle", json!({"x": x, "y": y, "width": w, "height": h})); + let stops = json!([{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#0000ff"}]); + run(&mut s, "paint.setFill", json!({"gradient": {"kind": "linear", "stops": stops}})); + run(&mut s, "transparency.set", json!({"opacity": 80})); + run(&mut s, "text.create", json!({"x": 50, "y": 50, "text": " ", "area": {"width": 80, "height": 60}})); + run(&mut s, "layer.clippingMask.toggle", json!({"id": layer})); + let reference = drawn(&read_back(&mut s, &json!({}))); + for flat in FLAT_FILES { + let p: Value = serde_json::from_str(flat).unwrap(); + let back = match s.execute("document.exportPdf", &p) { + Ok(v) => vectorcraft_pdf::import(&vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap()).unwrap(), + Err(e) => panic!("{flat}: {e}"), + }; + assert_eq!(lost(&drawn(&back), &reference), 0, "{flat}: the hidden art prints"); + } +} + +/// Object › Flatten Transparency of art in a clipping layer and art in the layer below that +/// reaches past the clipping path: when the layer below doesn't print, the clipping layer has +/// opacity, or the clipping path has a fill (see-through too, flattened keeping alpha, in a layer +/// with opacity or Multiply mode, or +/// over a layer with opacity, overlapping art of a layer with opacity or art an opacity mask +/// hides), the group stays in the clipping layer, the art below stays in its layer, and the page +/// looks the same. +#[test] +fn flattening_keeps_the_group_in_the_clipping_layer_when_the_layer_below_differs() { + let mut wrong = vec![]; + let cases = [ + "the layer below doesn't print", + "the clipping path has a fill", + "the clipping path has a see-through fill, keeping alpha", + "the clipping path has a see-through fill, in a layer with opacity", + "the clipping path has a see-through fill, in a layer in Multiply mode", + "the clipping path has a see-through fill, over a layer with opacity", + "the clipping path has a see-through fill, over art that an opacity mask hides", + "the clipping path has a see-through fill, over overlapping art of a layer at 50%", + "the clipping path has no fill, in a layer with opacity", + ]; + for case in cases { + let mut s = Session::new(); + run(&mut s, "file.new", json!({"width": 400, "height": 300, "colorMode": "rgb"})); + let below = s.doc().unwrap().doc.layers[0].id.0; + let orange = run(&mut s, "shape.rectangle", json!({"x": 20, "y": 20, "width": 200, "height": 150}))["id"].clone(); + run(&mut s, "paint.setFill", json!({"color": "#ff8000"})); + let upper = run(&mut s, "layer.new", json!({}))["id"].as_u64().unwrap(); + let blue = run(&mut s, "shape.rectangle", json!({"x": 100, "y": 80, "width": 200, "height": 150}))["id"].clone(); + run(&mut s, "paint.setFill", json!({"color": "#2040c0"})); + run(&mut s, "transparency.set", json!({"opacity": 50})); + let clip = run(&mut s, "shape.rectangle", json!({"x": 60, "y": 40, "width": 300, "height": 220}))["id"].clone(); + run(&mut s, "layer.clippingMask.toggle", json!({"id": upper})); + if case.starts_with("the layer below") { + run(&mut s, "layer.setProps", json!({"id": below, "printable": false})); + } else if !case.contains("no fill") { + run(&mut s, "paint.setFill", json!({"ids": [clip], "color": "#88ffcc"})); + } + let keeping_alpha = case.ends_with("keeping alpha"); + if case.contains("see-through fill") { + run(&mut s, "transparency.set", json!({"ids": [clip], "opacity": 50})); + } + if case.ends_with("in a layer with opacity") { + run(&mut s, "transparency.set", json!({"ids": [upper], "opacity": 80})); + } + if case.ends_with("over a layer with opacity") { + run(&mut s, "transparency.set", json!({"ids": [below], "opacity": 50})); + } + if case.ends_with("an opacity mask hides") { + run(&mut s, "layer.setCurrent", json!({"id": below})); + run(&mut s, "select.set", json!({"ids": [orange]})); + let group = run(&mut s, "object.group", json!({}))["id"].clone(); + let mask = run(&mut s, "shape.rectangle", json!({"x": 20, "y": 20, "width": 200, "height": 150}))["id"].clone(); + run(&mut s, "paint.setFill", json!({"color": "#000000"})); + run(&mut s, "select.set", json!({"ids": [group, mask]})); + run(&mut s, "transparency.makeOpacityMask", json!({})); + } + if case.ends_with("Multiply mode") { + run(&mut s, "transparency.set", json!({"ids": [upper], "blendMode": "multiply"})); + } + let mut art = vec![orange.clone()]; + if case.ends_with("a layer at 50%") { + run(&mut s, "layer.setCurrent", json!({"id": below})); + art.push(run(&mut s, "shape.rectangle", json!({"x": 80, "y": 60, "width": 120, "height": 120}))["id"].clone()); + run(&mut s, "paint.setFill", json!({"color": "#20a040"})); + run(&mut s, "transparency.set", json!({"ids": [below], "opacity": 50})); + } + art.push(blue.clone()); + // The page as it prints. + let printed = |d: &vectorcraft_doc::Document| { + let mut d = d.clone(); + if case.starts_with("the layer below") { + let mut layer = (*d.layers[0]).clone(); + layer.visible = false; + d.layers[0] = layer.into(); + } + drawn(&d) + }; + let before = printed(&s.doc().unwrap().doc); + run(&mut s, "select.set", json!({"ids": art})); + run(&mut s, "object.flattenTransparency", json!({"preserveAlpha": keeping_alpha})); + let d = &s.doc().unwrap().doc; + let layer_of = |id: &Value| id.as_u64().and_then(|id| d.ancestry(vectorcraft_doc::NodeId(id))).map(|a| a[0].0); + if layer_of(&orange) != Some(below) { + wrong.push(format!("{case}: the art below is in layer {:?}", layer_of(&orange))); + } + let upper_art = d.node(vectorcraft_doc::NodeId(upper)).and_then(|l| l.children()).map_or(0, Vec::len); + if d.node(vectorcraft_doc::NodeId(blue.as_u64().unwrap())).is_some() || upper_art != 2 { + wrong.push(format!("{case}: the clipping layer holds {upper_art} objects")); + } + let after = printed(d); + // Flat regions differ from see-through art along their antialiased edges. + let (lost, differ) = (lost(&before, &after), differ(&before, &after)); + if lost > 0 || differ > 1200 { + wrong.push(format!("{case}: {lost} pixels of art lost, {differ} pixels differ")); + } + } + assert!(wrong.is_empty(), "{wrong:#?}"); +} diff --git a/crates/engine/src/cmd/flatten/apply.rs b/crates/engine/src/cmd/flatten/apply.rs index c9459ed76..555058e58 100644 --- a/crates/engine/src/cmd/flatten/apply.rs +++ b/crates/engine/src/cmd/flatten/apply.rs @@ -1,4 +1,4 @@ -use super::analysis::{Scratch, outlined_text, overlapping, reach}; +use super::analysis::{Scratch, has_effects, masked, outlined_text, overlapping, reach}; use super::compose::{Builder, CLEAR, Composer, Elem, Fill, Kind, Px, WHITE}; use super::options::FlattenOptions; use super::{ @@ -108,30 +108,82 @@ pub(super) fn plan(src: &Document, roots: &[NodeId], o: &FlattenOptions, render: let mut sc = Scratch { brushes: vectorcraft_brush::library(&baked), doc: baked.clone() }; let plain: Vec = art.iter().map(|n| sc.plain(n)).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 clipped: Vec = plain.iter().zip(roots).map(|(n, id)| clipped_by_ancestors(src, *id, n, None)).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() }; // Baking above stopped early when cancelled; each group is a place to stop (a plan changes nothing). vectorcraft_doc::cancel::check()?; - for g in overlapping(&reaches)? { + let mut queue = overlapping(&reaches)?; + queue.reverse(); + while let Some(g) = queue.pop() { vectorcraft_doc::cancel::check()?; + // A group that stays inside the clip groups and clipping layers of its topmost object + // replaces only the objects inside them, over the others, which are flattened on their own. + let (g, under) = match inside_and_under(src, roots, &g) { + Some((inside, under)) => { + queue.push(under.clone()); + (inside, under) + } + None => (g, vec![]), + }; + // Composited over white, the group covers what it is drawn over, so it is composited over + // the fills of the clipping paths around it and the objects under it in the same kind of + // layer. Composited over nothing, it keeps its alpha and they show through. + let backdrop = !o.preserve_alpha; + let state = g.last().map(|&i| layer_state(src, roots[i])); + let top = g.last().map(|&i| roots[i]); + // A blend mode of a layer or group around the group alone would blend the art under it + // twice. + let blends = |i: usize| top.is_some_and(|t| own_layers(src, t, Some(roots[i])).iter().any(|a| a.blend != BlendMode::Normal)); + // Art under an opacity mask, effects or a knockout group is left out: the group can't draw it + // as those do. + let drawn_plainly = |i: usize| { + own_layers(src, roots[i], top).iter().all(|a| !masked(a) && !has_effects(a) && !matches!(a.knockout, vectorcraft_doc::Knockout::On)) + }; + let under_shown = under.iter().copied().filter(|&i| backdrop && Some(layer_state(src, roots[i])) == state && !blends(i) && drawn_plainly(i)); + let composited: Vec = under_shown.chain(g.iter().copied()).collect(); + let below = composited.len() - g.len(); + let extent = composited.iter().filter_map(|&i| reaches[i]).reduce(|a, b| a.union(b)).unwrap_or_default(); + let shown = |nodes: &[Node]| -> Vec { + let mut seen = vec![]; + let (mut under, mut out) = (vec![], vec![]); + for (k, &i) in composited.iter().enumerate() { + let n = clipped_by_ancestors(src, roots[i], &nodes[i], backdrop.then_some((&mut seen, extent))); + if k < below { + let groups = own_layers(src, roots[i], top).into_iter().filter(|a| !draws_as_is(a)).collect(); + under.push((groups, n)); + } else { + out.push(n); + } + } + let mut all = in_groups(under, 0); + all.extend(out); + all + }; 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| &clipped[i]).collect(); - flatten_group(&plain, o, cmyk) + let plain = shown(&plain); + flatten_group(&plain.iter().collect::>(), o, cmyk) }); let Some((regions, area)) = group.flatten() else { - out.kept.extend(ids); + // Transparent art that its clip groups or clipping layers hide entirely draws nothing: + // it is removed. + let hides = |i: usize| see_through[i] && reach(&plain[i]).is_some() && reach(&clipped[i]).is_none(); + let (hidden, left): (Vec, Vec) = g.iter().partition(|&&i| hides(i)); + if !hidden.is_empty() { + let roots = hidden.iter().map(|&i| roots[i]).collect(); + out.flat.push(Flat { roots, regions: vec![], area: None, raster: None, lines: None }); + } + out.kept.extend(left.iter().map(|&i| roots[i])); continue; }; let raster = match area.as_ref().filter(|_| render) { Some(a) => { // 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)?) + Some(render_raster(&baked, &shown(&art), a.rect, a.ppi, a.clip.clone(), o)?) } None => None, }; @@ -146,8 +198,10 @@ pub(super) fn plan(src: &Document, roots: &[NodeId], o: &FlattenOptions, render: /// `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 { +/// path. `plain` itself when nothing around it clips. With `fills` (clipping paths, and an area), +/// the fill of each clipping path not in them yet goes behind the art, inside the area, and the +/// path is added to them. +fn clipped_by_ancestors(src: &Document, id: NodeId, plain: &Node, mut fills: Option<(&mut Vec, Rect)>) -> Node { let mut out = plain.clone(); if !clips_count() { return out; @@ -170,13 +224,106 @@ fn clipped_by_ancestors(src: &Document, id: NodeId, plain: &Node) -> Node { 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)]); + let mut children = vec![Arc::new(path)]; + // The renderer paints the fill behind the clipped art. + if let Some((seen, r)) = fills.as_mut() + && !seen.contains(&clip.id) + && let Some(fill) = clip.clip_paint().fill + { + let r = *r; + seen.push(clip.id); + let mut at = Node::group(above.id, vec![Arc::new(Node::path(clip.id, shapes::rectangle(r), Appearance::default())), Arc::new(fill)]); + at.set_clips(true); + children.push(Arc::new(at)); + } + children.push(Arc::new(out)); + let mut g = Node::group(above.id, children); g.set_clips(true); out = g; } out } +/// Whether layer or group `n` draws what it holds as it is: opaque, in Normal mode, not isolated. +fn draws_as_is(n: &Node) -> bool { + n.opacity >= 1.0 && n.blend == BlendMode::Normal && !n.isolate +} + +/// `items` (in paint order, each an object and the layers and groups around it, outermost first, +/// that don't draw it as it is) with the objects of each such layer or group in one group with its +/// opacity, blend mode and isolation, from the `depth`th around them on: they then composite +/// together as that layer or group draws them. +fn in_groups(items: Vec<(Vec<&Node>, Node)>, depth: usize) -> Vec { + let mut out = vec![]; + let mut items = items.into_iter().peekable(); + while let Some((around, n)) = items.next() { + let Some(a) = around.get(depth).copied() else { + out.push(n); + continue; + }; + let mut run = vec![(around, n)]; + while let Some(next) = items.next_if(|(around, _)| around.get(depth).is_some_and(|b| b.id == a.id)) { + run.push(next); + } + let mut g = Node::group(a.id, in_groups(run, depth + 1).into_iter().map(Arc::new).collect()); + (g.opacity, g.blend, g.isolate) = (a.opacity, a.blend, a.isolate); + out.push(g); + } + out +} + +/// The layers and groups around object `id` of `src` that are not also around `other`, outermost +/// first. +fn own_layers(src: &Document, id: NodeId, other: Option) -> Vec<&Node> { + let mine = src.ancestry(id).unwrap_or_default(); + let theirs = other.and_then(|t| src.ancestry(t)).unwrap_or_default(); + let shared = mine.iter().zip(&theirs).take_while(|(a, b)| a == b).count(); + let own = mine.get(shared..mine.len().saturating_sub(1)).unwrap_or_default(); + own.iter().filter_map(|a| src.node(*a)).collect() +} + +/// The clip groups and clipping layers around object `id` of `d`, innermost first. +fn clip_ancestors(d: &Document, id: NodeId) -> Vec { + let mut a = d.ancestry(id).unwrap_or_default(); + a.pop(); + a.into_iter().rev().filter(|a| d.node(*a).is_some_and(Node::clips)).collect() +} + +/// Whether the layers around object `id` of `d` all print, all show, and whether one is a +/// template. +fn layer_state(d: &Document, id: NodeId) -> (bool, bool, bool) { + let mut a = d.ancestry(id).unwrap_or_default(); + a.pop(); + a.iter().filter_map(|a| d.node(*a)).fold((true, true, false), |(print, show, template), n| match &n.kind { + NodeKind::Layer { printable, template: t, .. } => (print && *printable, show && n.visible, template || *t), + _ => (print, show, template), + }) +} + +/// For group `g` (indices of `roots` of `src`, in paint order) whose flattened group can't leave +/// the clip groups and clipping layers of its topmost object ([`place_of`]): the objects inside +/// them and the objects under them. `None` when the group can leave them, or every object is +/// inside them. +fn inside_and_under(src: &Document, roots: &[NodeId], g: &[usize]) -> Option<(Vec, Vec)> { + if !clips_count() || g.len() < 2 { + return None; + } + let ids: Vec = g.iter().map(|&i| roots[i]).collect(); + let top = clip_ancestors(src, *ids.last()?); + if top.is_empty() { + return None; + } + let inside = |id: NodeId| { + let mine = clip_ancestors(src, id); + top.iter().all(|c| mine.contains(c)) + }; + if !inside(place_of(src, &ids)?) { + return None; + } + let (inside, under): (Vec, Vec) = g.iter().partition(|&&i| inside(roots[i])); + (!under.is_empty()).then_some((inside, under)) +} + /// 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)> { @@ -493,6 +640,17 @@ pub(super) fn apply(d: &mut Document, sel: &mut vectorcraft_doc::Selection, plan for f in plan.flat { // A group always has objects. let Some(top) = place_of(d, &f.roots) else { continue }; + // Art hidden entirely is removed. + if f.regions.is_empty() && f.area.is_none() { + if one_pass { + flat.extend(f.roots.iter().map(|r| (*r, vec![]))); + } else { + for r in &f.roots { + d.remove(*r)?; + } + } + 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. @@ -564,19 +722,26 @@ pub(super) fn apply(d: &mut Document, sel: &mut vectorcraft_doc::Selection, plan /// 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. +/// topmost one. The group stays with the topmost object rather than move to a layer that prints, +/// shows or is a template where the topmost object's layer differs, out from a clip group or +/// clipping layer whose clipping path has a fill, which would then cover it, or out from a layer +/// or group with opacity, a blend mode or isolation. fn place_of(d: &Document, roots: &[NodeId]) -> Option { - let top = roots.last().copied(); + let top = roots.last().copied()?; if !clips_count() || roots.len() < 2 { - return top; + return Some(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 fills = |clip: &NodeId| d.node(*clip).and_then(|n| n.children()?.first().map(|c| c.clip_paint().fill.is_some())).unwrap_or(false); + let all: Vec> = roots.iter().map(|r| clip_ancestors(d, *r)).collect(); + let above = all.last().map_or(&[][..], Vec::as_slice); 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) + let leaves_fill = |mine: &Vec| above.iter().any(|c| !mine.contains(c) && fills(c)); + // Out of a layer or group with opacity, a blend mode or isolation, the group would lose it. + let leaves_compositing = |r: NodeId| own_layers(d, top, Some(r)).iter().any(|a| !draws_as_is(a)); + let state = layer_state(d, top); + let place = + roots.iter().zip(&all).rev().find(|(r, mine)| shared(mine) && !leaves_fill(mine) && layer_state(d, **r) == state && !leaves_compositing(**r)); + Some(place.map_or(top, |(r, _)| *r)) } /// An image of `r` (in a clip group when it is clipped). diff --git a/crates/engine/src/cmd/flatten/commands.rs b/crates/engine/src/cmd/flatten/commands.rs index d528e40d8..53687713b 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 (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)}", + "{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, over its fill; transparent objects such a path hides entirely are removed); 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/docs/mcp.md b/docs/mcp.md index face50368..f979db154 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -836,7 +836,8 @@ page with hint tables saying where every other page's objects are), which stays `compatibility: "1.3"` writes a PDF 1.3 file, which has no transparency: a copy of the document is flattened first with `flattenerPreset` (empty: High Resolution; `high`, `medium`, `low` or a saved preset, `flattener.presets.list`) and `flattener` options over it (as `object.flattenTransparency` takes them); images with see-through pixels count as -transparency and rasterized areas are clipped to their regions. A file that would still have transparency fails the +transparency and rasterized areas are clipped to their regions. Layers the file leaves out (non-printing ones, unless +`includeNonPrinting` or `createLayers` is set) are left out of the flattening. A file that would still have transparency fails the export. PDF/X-1a and PDF/X-3 files are PDF 1.3 files too. A page with a side longer than 14,400 pt is written with a `UserUnit` (PDF 1.6): its longer side divided by 14,400, rounded up to the next thousandth, with the page's sizes and coordinates in those units, so that no side is longer than @@ -3062,7 +3063,12 @@ Each other group becomes one group of atomic regions: paths filled with the flat 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. +the hidden part of a cropped picture stays as it is. Transparent art that such a clipping path hides entirely is +removed. A group that holds art from inside and outside a clipping layer goes in the place of the topmost art outside +it. When that layer prints, shows or is a template where the clipping layer differs, the clipping layer or a group +around the art has opacity, a blend mode or isolation, or the clipping path has a fill, +the group holds only the art inside the clipping layer, drawn over the fill and over the art below in the same kind of +layer as its own layers draw it (over nothing with `preserveAlpha`), and the art outside is flattened on its own. `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 5e291ded5..22a7cbb82 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1413,7 +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`). + 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. Layers the file leaves out are left out of the flattening, and transparent art that a clipping path hides entirely is removed (`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`).