diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 957a1269..1cfce9c6 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -334,6 +334,14 @@ pub struct Document { /// Foreign data preserved on round-trip. #[serde(default, skip_serializing_if = "serde_json::Map::is_empty")] pub unknown: serde_json::Map, + /// Transparency panel → Page Isolated Blending: the page is an isolated transparency group, + /// so top-level blend modes don't blend with what lies under the page. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub page_isolate: bool, + /// Transparency panel → Page Knockout Group: the page's elements (its layers; neutral layers + /// pass their contents through) knock each other out. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub page_knockout: bool, } fn ppi72() -> f64 { @@ -377,6 +385,8 @@ impl Document { mask_edit: None, next_id: 1, unknown: Default::default(), + page_isolate: false, + page_knockout: false, }; let id = d.alloc_id(); d.layers.push(Arc::new(Node::layer(id, "Layer 1", LayerColor::Preset(0)))); diff --git a/crates/engine/src/cmd/opacitymask.rs b/crates/engine/src/cmd/opacitymask.rs index 77f90c74..6cec3b24 100644 --- a/crates/engine/src/cmd/opacitymask.rs +++ b/crates/engine/src/cmd/opacitymask.rs @@ -108,10 +108,28 @@ pub fn specs() -> Vec { "Transparency Info", [], None, - "{id?|ids?} → {ids, opacity: 0..100, blend, isolate, knockout: \"on\"|\"off\"|\"neutral\", knockoutShape, editingMask} the Transparency panel's values for `ids`, the selection, or the object whose mask is being edited. A value is null where those objects differ (or there are none); editingMask is the id of the object whose mask is being edited, or null", + "{id?|ids?} → {ids, opacity: 0..100, blend, isolate, knockout: \"on\"|\"off\"|\"neutral\", knockoutShape, editingMask, pageIsolatedBlending, pageKnockoutGroup} the Transparency panel's values for `ids`, the selection, or the object whose mask is being edited. A value is null where those objects differ (or there are none); editingMask is the id of the object whose mask is being edited, or null; the page values are the document's", has_doc, info ), + cmd!( + "transparency.togglePageIsolatedBlending", + "Page Isolated Blending", + ["Window", "Transparency"], + None, + "{value?} make the page an isolated group (default: toggle): blend modes of top-level objects don't blend with what lies under the page; saved with the document and written to PDF → {value}", + has_doc, + |s, p| toggle_page(s, p, false) + ), + cmd!( + "transparency.togglePageKnockoutGroup", + "Page Knockout Group", + ["Window", "Transparency"], + None, + "{value?} make the page a knockout group (default: toggle): its layers (the contents of neutral layers) hide what they cover instead of showing it through their transparency; saved with the document and written to PDF → {value}", + has_doc, + |s, p| toggle_page(s, p, true) + ), ] } @@ -248,6 +266,24 @@ fn toggle_new_clip(s: &mut Session, p: &Value) -> Result { Ok(json!({ "value": v })) } +/// Set (or toggle) the page's knockout group flag (`knockout`) or isolated blending flag, as one +/// undo step; setting the current value changes nothing. +fn toggle_page(s: &mut Session, p: &Value, knockout: bool) -> Result { + fn flag(d: &mut Document, knockout: bool) -> &mut bool { + if knockout { &mut d.page_knockout } else { &mut d.page_isolate } + } + let d = &s.doc()?.doc; + let cur = if knockout { d.page_knockout } else { d.page_isolate }; + let v = bool_or(p, "value", !cur); + if v != cur { + s.edit(if knockout { "Page Knockout Group" } else { "Page Isolated Blending" }, |d, _| { + *flag(d, knockout) = v; + Ok(()) + })?; + } + Ok(json!({ "value": v })) +} + fn toggle_new_invert(s: &mut Session, p: &Value) -> Result { let v = bool_or(p, "value", !s.menu.new_masks_inverted); s.menu.new_masks_inverted = v; @@ -329,6 +365,8 @@ fn info(s: &mut Session, p: &Value) -> Result { "knockout": i.knockout.map(Knockout::label), "knockoutShape": i.knockout_shape, "editingMask": st.doc.mask_edit.map(|me| me.object.0), + "pageIsolatedBlending": st.doc.page_isolate, + "pageKnockoutGroup": st.doc.page_knockout, })) } diff --git a/crates/engine/src/tests_knockout.rs b/crates/engine/src/tests_knockout.rs index f94eeefa..26e27cc0 100644 --- a/crates/engine/src/tests_knockout.rs +++ b/crates/engine/src/tests_knockout.rs @@ -1,4 +1,4 @@ -//! Knockout groups and the knockout shape (M3.46–M3.47): commands, undo, files +//! Knockout groups, the knockout shape and the page group (M3.46–M3.48): commands, undo, files //! (old bools load) and the PDF and SVG output. use serde_json::{Value, json}; @@ -80,6 +80,25 @@ fn knockout_shape_is_an_object_option() { assert!(!node(&s, child).knockout_shape); } +#[test] +fn page_group_toggles_are_undoable_document_settings() { + let mut s = session(); + let doc = |s: &Session| (s.doc().unwrap().doc.page_isolate, s.doc().unwrap().doc.page_knockout); + let before = steps(&s); + assert_eq!(run(&mut s, "transparency.togglePageIsolatedBlending", json!({})), json!({"value": true})); + assert_eq!(run(&mut s, "transparency.togglePageKnockoutGroup", json!({"value": true})), json!({"value": true})); + assert_eq!((doc(&s), steps(&s)), ((true, true), before + 2)); + // Setting the current value is not a change. + run(&mut s, "transparency.togglePageKnockoutGroup", json!({"value": true})); + assert_eq!(steps(&s), before + 2); + let info = run(&mut s, "transparency.info", json!({})); + assert_eq!((info["pageIsolatedBlending"].clone(), info["pageKnockoutGroup"].clone()), (json!(true), json!(true))); + run(&mut s, "edit.undo", json!({})); + assert_eq!(doc(&s), (true, false)); + run(&mut s, "transparency.togglePageIsolatedBlending", json!({})); + assert_eq!(doc(&s), (false, false)); +} + #[test] fn knockout_settings_round_trip_and_old_bools_load() { let mut s = session(); @@ -87,13 +106,15 @@ fn knockout_settings_round_trip_and_old_bools_load() { let child = node(&s, g).children().unwrap()[0].id.0; run(&mut s, "transparency.set", json!({"ids": [g], "knockout": "off"})); run(&mut s, "transparency.set", json!({"ids": [child], "knockout": "on", "knockoutShape": true})); + run(&mut s, "transparency.togglePageIsolatedBlending", json!({})); + run(&mut s, "transparency.togglePageKnockoutGroup", json!({})); let bytes = vectorcraft_format::save(&s.doc().unwrap().doc, false); let back = vectorcraft_format::load(&bytes).unwrap(); let (bg, bc) = (back.node(NodeId(g)).unwrap(), back.node(NodeId(child)).unwrap()); - assert_eq!((bg.knockout, bc.knockout, bc.knockout_shape), (Knockout::Off, Knockout::On, true)); + assert_eq!((bg.knockout, bc.knockout, bc.knockout_shape, back.page_isolate, back.page_knockout), (Knockout::Off, Knockout::On, true, true, true)); // Defaults are not written. let text = String::from_utf8(vectorcraft_format::save(&session().doc().unwrap().doc, false)).unwrap(); - assert!(!text.contains("knockout"), "{text}"); + assert!(!text.contains("knockout") && !text.contains("page_isolate"), "{text}"); // Files from before the three states stored a bool: true is On, false is Neutral. let mut v: Value = serde_json::from_slice(&bytes).unwrap(); let mut patched = 0; @@ -145,4 +166,12 @@ fn pdf_and_svg_write_knockout_groups_as_masks() { run(&mut s, "transparency.set", json!({"ids": [g], "knockout": "neutral"})); run(&mut s, "transparency.set", json!({"ids": [outer], "knockout": "on"})); assert_eq!(svg(&s.doc().unwrap().doc).matches("mask=\"url(#knockout-").count(), 1); + + // The page group: an isolated group around the page content. + let mut page = (*plain).clone(); + page.page_isolate = true; + assert!(svg(&page).contains("isolation")); + page.page_knockout = true; + assert_eq!(svg(&page).matches("mask=\"url(#knockout-").count(), 1, "the layer is neutral: its objects knock each other out"); + assert!(String::from_utf8_lossy(&pdf(&page).bytes).contains("/Luminosity")); } diff --git a/crates/engine/src/tests_opacitymask.rs b/crates/engine/src/tests_opacitymask.rs index ca25ca14..3cfba760 100644 --- a/crates/engine/src/tests_opacitymask.rs +++ b/crates/engine/src/tests_opacitymask.rs @@ -182,7 +182,7 @@ fn info_reports_mixed_values_as_null() { let info = s.execute("transparency.info", &json!({})).unwrap(); assert_eq!( info, - json!({"ids": [a.0, b.0], "opacity": null, "blend": "Screen", "isolate": null, "knockout": "neutral", "knockoutShape": false, "editingMask": null}) + json!({"ids": [a.0, b.0], "opacity": null, "blend": "Screen", "isolate": null, "knockout": "neutral", "knockoutShape": false, "editingMask": null, "pageIsolatedBlending": false, "pageKnockoutGroup": false}) ); let one = s.execute("transparency.info", &json!({"id": a.0})).unwrap(); assert_eq!((one["opacity"].clone(), one["isolate"].clone()), (json!(50.0), json!(true))); diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index 69fd2129..ad0cff06 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -70,7 +70,7 @@ pub fn export_with_report(doc: &Document, opts: &PdfOptions) -> Result Result>()); + } else { + for layer in layers { + self.draw_arc(&mut ctx, &frame, layer); + } + } + if page_group { + ctx.pop_layer(); + } + self.knockout = false; } if trim { ctx.pop_layer(); diff --git a/crates/render/src/tests_knockout.rs b/crates/render/src/tests_knockout.rs index b583b8e1..a5176608 100644 --- a/crates/render/src/tests_knockout.rs +++ b/crates/render/src/tests_knockout.rs @@ -1,7 +1,7 @@ -//! Knockout groups and the knockout shape (Transparency panel). +//! Knockout groups, the knockout shape and the page group (Transparency panel). use super::*; -use vectorcraft_color::{Color, Paint}; +use vectorcraft_color::{BlendMode as Blend, Color, Paint}; use vectorcraft_doc::{Appearance, Knockout, Node}; use vectorcraft_geom::shapes; @@ -105,3 +105,24 @@ fn neutral_groups_pass_knockout_through() { Arc::make_mut(&mut Arc::make_mut(&mut plain.layers[0]).children_mut().unwrap()[0]).knockout = Knockout::Neutral; assert!(close(render(&plain).pixel(50, 50), BLUE_OVER_RED)); } + +#[test] +fn page_knockout_and_isolated_blending() { + // Page Knockout Group: the neutral layer passes its objects through to the page. + let mut d = doc(Knockout::Neutral, |_, _| {}); + d.page_knockout = true; + assert!(close(render(&d).pixel(50, 50), BLUE_OVER_WHITE), "{:?}", render(&d).pixel(50, 50)); + // A layer set to Off keeps its objects from knocking each other out. + Arc::make_mut(&mut d.layers[0]).knockout = Knockout::Off; + assert!(close(render(&d).pixel(50, 50), BLUE_OVER_RED)); + + // Page Isolated Blending: a Difference object no longer blends with the white under the page. + let mut d = Document::new(100.0, 100.0); + let mut red = half(&mut d, 10.0, (1.0, 0.0, 0.0)); + red.opacity = 1.0; + red.blend = Blend::Difference; + d.insert(Some(d.layers[0].id), 0, red).unwrap(); + assert!(close(render(&d).pixel(30, 50), [0, 255, 255, 255]), "inverts the white: {:?}", render(&d).pixel(30, 50)); + d.page_isolate = true; + assert!(close(render(&d).pixel(30, 50), [255, 0, 0, 255]), "isolated: {:?}", render(&d).pixel(30, 50)); +} diff --git a/crates/svg/src/export.rs b/crates/svg/src/export.rs index 3062de33..8c4e0753 100644 --- a/crates/svg/src/export.rs +++ b/crates/svg/src/export.rs @@ -44,13 +44,22 @@ pub(crate) fn export(doc: &Document, opts: &ExportOptions) -> String { depth: 1, patterns: HashMap::new(), pattern_nest: 0, - knockout: false, + knockout: doc.page_knockout, anonymous: false, knockout_filter: None, }; w.assign_name_ids(); - for l in &doc.layers { - w.node(l); + // Page Isolated Blending / Page Knockout Group: the page content is one isolated group. + let page_group = doc.page_isolate || doc.page_knockout; + if page_group { + let a = w.attrs(&vec![("isolation", "isolate".into())]); + w.line(&format!("")); + w.depth += 1; + } + w.children(&doc.layers); + if page_group { + w.depth -= 1; + w.line(""); } let nl = if opts.minify { "" } else { "\n" }; diff --git a/crates/ui-egui/src/panels/transparency.rs b/crates/ui-egui/src/panels/transparency.rs index 995bd02a..52649285 100644 --- a/crates/ui-egui/src/panels/transparency.rs +++ b/crates/ui-egui/src/panels/transparency.rs @@ -1,7 +1,7 @@ //! Transparency panel: blend mode, opacity (field + slider popup), object/mask thumbnails with the //! opacity-mask controls (make/release, link, clip, invert; Shift-click the mask to disable it), //! Isolate Blending, the three-state Knockout Group (on → neutral → off) and Opacity & Mask Define -//! Knockout Shape. +//! Knockout Shape; the menu's Page Isolated Blending and Page Knockout Group. use egui::{Sense, Stroke, StrokeKind, Ui, vec2}; use serde_json::json; @@ -200,8 +200,14 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { app.run("transparency.toggleNewMasksInverted", json!({})).ok(); } ui.separator(); - menu_item(ui, "Page Isolated Blending", false, false); - menu_item(ui, "Page Knockout Group", false, false); + let page = app.session.active().map(|d| (d.doc.page_isolate, d.doc.page_knockout)); + let (isolate, knockout) = page.unwrap_or_default(); + if menu_item(ui, "Page Isolated Blending", page.is_some(), isolate) { + app.run("transparency.togglePageIsolatedBlending", json!({})).ok(); + } + if menu_item(ui, "Page Knockout Group", page.is_some(), knockout) { + app.run("transparency.togglePageKnockoutGroup", json!({})).ok(); + } } /// The menu's opacity-mask rows (label, command, enabled) for the target's `mask`; Make needs a @@ -358,7 +364,7 @@ mod tests { } #[test] - fn knockout_states() { + fn knockout_states_and_page_group_items() { let mut app = VectorcraftApp::new(Session::new(), Default::default()); run(&mut app, "file.new", json!({"width": 100, "height": 100})); run(&mut app, "shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50})); @@ -367,5 +373,10 @@ mod tests { let texts = frame(&mut app); assert!(texts.iter().any(|t| t == "Knockout Group") && texts.iter().any(|t| t == "Opacity & Mask Define Knockout Shape")); } + assert!(frame(&mut app).iter().any(|t| t.ends_with(" Page Knockout Group") && !t.starts_with('✓'))); + run(&mut app, "transparency.togglePageKnockoutGroup", json!({})); + run(&mut app, "transparency.togglePageIsolatedBlending", json!({})); + let texts = frame(&mut app); + assert!(texts.iter().any(|t| t == "✓ Page Knockout Group") && texts.iter().any(|t| t == "✓ Page Isolated Blending"), "{texts:?}"); } } diff --git a/docs/mcp.md b/docs/mcp.md index 0ebe60b5..e34c9051 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -188,7 +188,9 @@ until `transparency.stopEditingOpacityMask`. Saves and exports never include the Knockout Group has three states: `transparency.set {knockout: "on"|"off"|"neutral"}` (`true` = on, `false` = neutral, the default). In a knockout group each child hides what it covers of the children below it; neutral groups pass the enclosing group's setting to their children, off groups never knock out. `knockoutShape: true` makes the -object's opacity and opacity mask scale how much it knocks out. Knockout groups render as isolated groups; +object's opacity and opacity mask scale how much it knocks out. `transparency.togglePageKnockoutGroup` and +`transparency.togglePageIsolatedBlending` (`{value?}`) treat the whole page as a knockout or isolated group; they are +saved with the document and undoable, and `transparency.info` reports them. Knockout groups render as isolated groups; PDF and SVG write them as soft-masked groups with the same look (the PDF export reports a warning). ## Clipping masks