diff --git a/crates/pdf/src/export/nodes.rs b/crates/pdf/src/export/nodes.rs index 403cbc5431..71b65e65d0 100644 --- a/crates/pdf/src/export/nodes.rs +++ b/crates/pdf/src/export/nodes.rs @@ -859,7 +859,9 @@ impl Exporter<'_> { /// Opens the blend mode and the transparency group `n` is drawn with (`container` when it is a /// group or layer whose children `knockout` each other). Returns how many were opened, and /// whether the children are to be drawn in an isolated group of their own: the knockout group of -/// an unclipped group's elements is its isolated group too. +/// an unclipped group's elements is its isolated group too. An opaque knockout group in Normal +/// mode that does not isolate opens no group: its elements are drawn on the art below it, in a +/// knockout group without `/I` when there are several. fn transparency(s: &mut Surface, n: &Node, page: Rect, container: bool, knockout: bool) -> (usize, bool) { let mut pushes = 0; if n.blend != BlendMode::Normal { @@ -876,6 +878,8 @@ fn transparency(s: &mut Surface, n: &Node, page: Rect, container: bool, knockout } else if n.opacity < 1.0 { s.push_opacity(norm(n.opacity)); pushes += 1; + } else if knockout && !n.isolate && n.blend == BlendMode::Normal { + // A knockout group that does not isolate: its elements start from the art below it. } else if knockout && matches!(n.kind, NodeKind::Layer { clip: false, .. } | NodeKind::Group { clip: false, .. }) { isolated_children = true; } else { diff --git a/crates/pdf/src/tests/blend.rs b/crates/pdf/src/tests/blend.rs index c55a0b7504..1cd5c949e0 100644 --- a/crates/pdf/src/tests/blend.rs +++ b/crates/pdf/src/tests/blend.rs @@ -151,6 +151,77 @@ fn a_knockout_group_is_written_as_one_pdf_knockout_group() { } } +/// A knockout group with nothing blending inside is drawn in an isolated group (`/I true`) only +/// when the group isolates, clipped or not, with opaque or translucent elements, and reads back +/// knocking out. +#[test] +fn a_knockout_group_without_blending_inside_is_isolated_only_when_it_isolates() { + for clip in [false, true] { + for opacity in [0.5, 1.0] { + for isolate in [false, true] { + let mut d = knockout_squares(3, false, isolate); + let g = d.layers[0].children().unwrap()[1].id; + let mut squares: Vec<_> = d.node(g).unwrap().children().unwrap().to_vec(); + for s in &mut squares { + std::sync::Arc::make_mut(s).opacity = opacity; + } + if clip { + let c = Node::path( + d.alloc_id(), + shapes::rectangle(Rect::new(10.0, 10.0, 190.0, 190.0)), + Appearance::basic(Paint::None, Paint::None, 0.0), + ); + squares.insert(0, std::sync::Arc::new(c)); + } + d.node_mut(g).unwrap().kind = vectorcraft_doc::NodeKind::Group { children: squares, clip }; + let case = format!("clip {clip}, opacity {opacity}, isolate {isolate}"); + let t = text(&d); + assert_eq!(t.matches("/Ktrue").count(), 1, "{case}"); + // Each translucent element is an isolated form with its opacity. + let elements = if opacity < 1.0 { 3 } else { 0 }; + assert_eq!(t.matches("/Itrue").count(), elements + usize::from(isolate), "{case}"); + let back = import(&export(&d, &PdfOptions::default()).unwrap()).unwrap_or_else(|e| panic!("{case}: {e}")); + let mut found = vec![]; + back.walk(|n| { + if n.knockout == vectorcraft_doc::Knockout::On { + found.push(n.isolate); + } + }); + assert_eq!(found, [false], "{case}"); + } + } + } +} + +/// An overprinting element of a knockout group that does not isolate is drawn in a `/K` group +/// without `/I true`, so it overprints the art below the group, and its graphics state still +/// overprints (`/OP`, `/op`, `/OPM 1`). An isolated group keeps `/I true`. +#[test] +fn an_overprinting_element_of_a_knockout_group_that_does_not_isolate_overprints_the_art_below() { + for isolate in [false, true] { + let mut d = knockout_squares(3, false, isolate); + let g = d.layers[0].children().unwrap()[1].id; + let squares: Vec<_> = d.node(g).unwrap().children().unwrap().iter().map(|s| s.id).collect(); + for s in &squares { + d.node_mut(*s).unwrap().opacity = 1.0; + } + d.node_mut(squares[2]).unwrap().appearance.fill_mut().unwrap().overprint = true; + let t = text(&d); + assert_eq!(t.matches("/Ktrue").count(), 1, "isolate {isolate}"); + assert_eq!(t.matches("/Itrue").count(), usize::from(isolate), "isolate {isolate}"); + assert!(t.contains("/OPtrue/optrue/OPM1"), "isolate {isolate}"); + let back = import(&export(&d, &PdfOptions::default()).unwrap()).unwrap_or_else(|e| panic!("isolate {isolate}: {e}")); + let mut overprints = vec![]; + back.walk(|n| { + if let Some(f) = n.appearance.fill() { + overprints.push(f.overprint); + } + }); + assert_eq!(overprints, [false, false, false, true], "isolate {isolate}"); + assert_eq!(knockout_groups(&back), [3], "isolate {isolate}"); + } +} + /// The knockout group of an isolated group is isolated too (`/I true` beside `/K true`), and the /// group reads back as isolated and knocking out. (Each translucent element is a group of its own.) #[test] diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 74ce58c5d6..3ca89ce655 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1701,6 +1701,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur On the canvas, the renderer no longer draws the picture of the art below back as an image each time it copies the backdrop of a non-isolated group that blends: it keeps that picture and draws the art that follows over it. At 2880 × 1800, 50 translucent groups with Multiply members take 65 ms a frame instead of 299 ms on 8 threads (`vectorcraft-cli bench`), and the frames have the same pixels. Type inside a group, layer, symbol instance, blend or repeat keeps its opacity, blend mode, opacity mask, Opacity & Mask Define Knockout Shape, own effects, perspective and visibility when the effects on the container outline it. Hidden type with a geometry effect of its own is left out of exports. PDF export writes a knockout group as one PDF knockout group (`/K true`, with `/I true` when the group isolates; ISO 32000-1, 11.4.6.3), so a knockout group of any size opens again in VectorCraft as a knockout group, and the form nesting of the file does not grow with the number of elements. When an element's opacity or mask defines its knockout shape, or it paints translucently, each element is drawn through one soft mask of the overlapping elements above it instead; past 65,536 objects in a group's masks (or 2,048 elements) the group is a PDF knockout group, with a warning that its translucent elements knock out fully. SVG export draws each element of a knockout group through one mask of the overlapping elements above it, and the masks refer to one copy of each element's shape, so the elements stay siblings and the file nests no deeper with more elements; past 65,536 shapes in a group's masks (or 2,048 elements) the group is written as a plain group, with a warning. Evidence: `pdf/src/tests/blend.rs`, `svg/tests/it/blend_export.rs`, `pdf/tests/gradient_forms.rs` (a gradient in a knockout group reads back in place). + A knockout group in Normal mode at full opacity that does not isolate, with nothing blending inside, is written without an isolated group around its elements, clipped or not, so the art below the group is its initial backdrop in the PDF as on the canvas (ISO 32000-1, 11.4.5): neither its `/K` group nor a form around a clipped group carries `/I true`, and an overprinting element in it overprints the art below the group. Evidence: `pdf/src/tests/blend.rs` (`a_knockout_group_without_blending_inside_is_isolated_only_when_it_isolates`, `an_overprinting_element_of_a_knockout_group_that_does_not_isolate_overprints_the_art_below`). - **Graphic styles:** styles keep opacity and blend mode, capture groups and type, apply on top with Alt, and link to the objects using them; Redefine, Break Link, Graphic Style Options, Select All Unused, Sort by Name, and Select > Same Graphic Style / Appearance Attribute. The panel has list and thumbnail views with rendered previews, merge, reorder and drag and drop, plus a Control bar Style picker; eleven generated style libraries (including Game HUD, Status Effects, Neon & Glow, Painted and Sketched, whose seven styles each repaint a solid fill with one watercolor, dry brush, pastel, charcoal or ink effect, and Textures) save and load as `.vcstyles`. The built-in Textures graphic style library (`graphicStyle.addFromLibrary {library: "textures"}`) has eight styles that paint their fills and strokes with the texture effects: Fine Grain (Grain), Canvas Weave and Burlap Sack (Texturizer), Cracked Glaze (Craquelure), Mosaic Floor (Mosaic Tiles), Quilted Squares (Patchwork), Leaded Glass (Stained Glass) and Damp Paper (Water Paper). The help page names new graphic styles for textures ([What's new](https://helpx.adobe.com/illustrator/desktop/new-features/whats-new.html)) without naming them, so the styles, their names and their settings are VectorCraft's own (`tests::stylelib::textures_styles_apply_their_texture_effects_and_keep_their_params_through_save_and_open`). In the Graphic Styles panel, Shift-click selects a range of styles and Ctrl/Cmd-click adds or removes one.