M3.48: Page Isolated Blending and Page Knockout Group
New Document.page_isolate / page_knockout, set by
transparency.togglePageIsolatedBlending / togglePageKnockoutGroup {value?}
(one undo step each, saved with the document, reported by
transparency.info) and the Transparency panel menu's check items. The page
renders as an isolated group (top-level blend modes no longer blend with
what lies under the page) and/or a knockout group whose elements are the
layers (neutral layers pass their objects through). PDF and SVG wrap the
page content in an isolated group and write page knockout as soft masks.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
c95098d15a
commit
039c0cbef7
|
|
@ -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<String, serde_json::Value>,
|
||||
/// 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))));
|
||||
|
|
|
|||
|
|
@ -108,10 +108,28 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"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<Value> {
|
|||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
|||
"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,
|
||||
}))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ pub fn export_with_report(doc: &Document, opts: &PdfOptions) -> Result<ExportRep
|
|||
}
|
||||
pdf.set_metadata(meta);
|
||||
|
||||
let mut ex = Exporter { doc, warnings: vec![], images: HashMap::new(), knockout: false };
|
||||
let mut ex = Exporter { doc, warnings: vec![], images: HashMap::new(), knockout: doc.page_knockout };
|
||||
for i in indices {
|
||||
let ab = &doc.artboards[i];
|
||||
let r = ab.rect;
|
||||
|
|
@ -79,8 +79,15 @@ pub fn export_with_report(doc: &Document, opts: &PdfOptions) -> Result<ExportRep
|
|||
let mut s = page.surface();
|
||||
// krilla's page space is y-down with the origin at the top-left corner, like ours.
|
||||
s.push_transform(&xf(Affine::translate((-r.x0, -r.y0))));
|
||||
for layer in &doc.layers {
|
||||
ex.node(&mut s, layer, r, false);
|
||||
// Page Isolated Blending / Page Knockout Group: the page content is one group (the PDF
|
||||
// writer has no page group attributes).
|
||||
let page_group = doc.page_isolate || doc.page_knockout;
|
||||
if page_group {
|
||||
s.push_isolated();
|
||||
}
|
||||
ex.children(&mut s, &doc.layers, r);
|
||||
if page_group {
|
||||
s.pop();
|
||||
}
|
||||
s.pop();
|
||||
s.finish();
|
||||
|
|
|
|||
|
|
@ -336,10 +336,25 @@ impl Renderer {
|
|||
if !self.draw_pattern_edit(&mut ctx, &frame) {
|
||||
// While editing an opacity mask its art is seen only through the mask (Illustrator).
|
||||
let mask_layer = doc.mask_edit.map(|m| m.layer);
|
||||
self.knockout = false;
|
||||
for layer in doc.layers.iter().filter(|l| Some(l.id) != mask_layer) {
|
||||
self.draw_arc(&mut ctx, &frame, layer);
|
||||
let layers = doc.layers.iter().filter(|l| Some(l.id) != mask_layer);
|
||||
// Page Isolated Blending / Page Knockout Group: the page is a group of its own.
|
||||
let page_group = !opts.outline && (doc.page_isolate || doc.page_knockout);
|
||||
self.knockout = page_group && doc.page_knockout;
|
||||
if page_group {
|
||||
ctx.set_transform(Affine::IDENTITY);
|
||||
ctx.push_layer(None, None, None, None, None);
|
||||
}
|
||||
if self.knockout {
|
||||
self.draw_knockout(&mut ctx, &frame, &layers.cloned().collect::<Vec<_>>());
|
||||
} 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();
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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!("<g{a}>"));
|
||||
w.depth += 1;
|
||||
}
|
||||
w.children(&doc.layers);
|
||||
if page_group {
|
||||
w.depth -= 1;
|
||||
w.line("</g>");
|
||||
}
|
||||
|
||||
let nl = if opts.minify { "" } else { "\n" };
|
||||
|
|
|
|||
|
|
@ -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:?}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue