From 0c82a3f5c5e4d53da95fcf4a6eaf80860721d6f5 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 4 Oct 2026 23:46:40 +0700 Subject: [PATCH 1/2] M3.49: each fill and stroke can overprint Fill and stroke layers (and type characters' fill and stroke) carry an overprint flag. object.setOverprint {fill?, stroke?, item?, ids?} sets it (groups: their contents; type: its characters too; one undo step, none when nothing changes) and attributes.info reads it back (null where mixed). Overprint Preview and Separations Preview draw overprinting fills and strokes with Multiply. The old Document.unknown["overprintBlack"] id list migrates to the new flags on load; Overprint Black now sets the flags. Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/appearance.rs | 23 ++- crates/doc/src/lib.rs | 1 + crates/doc/src/overprint.rs | 203 +++++++++++++++++++++++++++ crates/doc/src/text.rs | 20 +++ crates/engine/src/cmd/colormgmt.rs | 55 +------- crates/engine/src/cmd/mod.rs | 5 + crates/engine/src/cmd/overprint.rs | 203 +++++++++++++++++++++++++++ crates/engine/src/cmd/pathops.rs | 17 +-- crates/engine/src/cmd/typecmd.rs | 4 +- crates/engine/src/lib.rs | 2 + crates/engine/src/tests_colormgmt.rs | 2 +- crates/engine/src/tests_overprint.rs | 194 +++++++++++++++++++++++++ crates/format/src/lib.rs | 2 + crates/render/src/lib.rs | 11 ++ crates/render/src/live.rs | 9 +- crates/render/src/proof.rs | 65 +++++---- docs/mcp.md | 14 ++ 17 files changed, 720 insertions(+), 110 deletions(-) create mode 100644 crates/doc/src/overprint.rs create mode 100644 crates/engine/src/cmd/overprint.rs create mode 100644 crates/engine/src/tests_overprint.rs diff --git a/crates/doc/src/appearance.rs b/crates/doc/src/appearance.rs index 2092c480..775410bf 100644 --- a/crates/doc/src/appearance.rs +++ b/crates/doc/src/appearance.rs @@ -184,11 +184,15 @@ pub struct FillLayer { pub visible: bool, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub effects: Vec, + /// Overprint Fill: on press the fill's inks print over the inks below instead of knocking + /// them out (Overprint Preview and Separations Preview show it). + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + pub overprint: bool, } impl FillLayer { pub fn new(paint: Paint) -> Self { - Self { paint, opacity: 1.0, blend: BlendMode::Normal, visible: true, effects: vec![] } + Self { paint, opacity: 1.0, blend: BlendMode::Normal, visible: true, effects: vec![], overprint: false } } } @@ -230,6 +234,9 @@ pub struct StrokeLayer { /// Arrowhead placement at both ends. #[serde(default, skip_serializing_if = "crate::skip::is_default")] pub arrow_align: ArrowAlign, + /// Overprint Stroke (see [`FillLayer::overprint`]). + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + pub overprint: bool, } fn ten() -> f64 { @@ -265,6 +272,7 @@ impl StrokeLayer { visible: true, effects: vec![], arrow_align: ArrowAlign::Extend, + overprint: false, } } /// Weight of the start (`end == false`) or end arrowhead: stroke weight × its scale, at least @@ -344,6 +352,19 @@ impl AppearanceItem { AppearanceItem::Stroke(s) => &mut s.effects, } } + /// Whether this fill or stroke overprints. + pub fn overprint(&self) -> bool { + match self { + AppearanceItem::Fill(f) => f.overprint, + AppearanceItem::Stroke(s) => s.overprint, + } + } + pub fn overprint_mut(&mut self) -> &mut bool { + match self { + AppearanceItem::Fill(f) => &mut f.overprint, + AppearanceItem::Stroke(s) => &mut s.overprint, + } + } } /// Appearance attributes. `items` is in paint order: `items[0]` is painted first (the bottom of the diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 2faf4195..a24dd3db 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -10,6 +10,7 @@ pub mod graph; pub mod hit; pub mod live; pub mod node; +pub mod overprint; pub mod pattern; pub mod selection; pub mod swatches; diff --git a/crates/doc/src/overprint.rs b/crates/doc/src/overprint.rs new file mode 100644 index 00000000..25f4487d --- /dev/null +++ b/crates/doc/src/overprint.rs @@ -0,0 +1,203 @@ +//! Overprinting: each fill and stroke (and each character's fill and stroke) can print its inks +//! over the inks below instead of knocking them out ([`crate::FillLayer::overprint`]). +//! Edit → Edit Colors → Overprint Black sets it on black fills and strokes ([`OverprintBlack`]). + +use vectorcraft_color::{Color, Paint}; + +use crate::{Document, Node, NodeId, NodeKind}; + +/// `Document.unknown` key of files from before per-fill/stroke overprint: the ids of objects +/// marked Overprint Black ([`Document::migrate_overprint_black`]). +pub const LEGACY_OVERPRINT_KEY: &str = "overprintBlack"; + +/// Black ink of at least `min_k` (0..1): CMYK with that much K and, unless `rich`, no cyan, +/// magenta or yellow; or a grey that dark. RGB colours are never black ink. +pub fn is_black_ink(c: &Color, min_k: f32, rich: bool) -> bool { + const E: f32 = 0.005; + match *c { + Color::Cmyk { c, m, y, k } => k >= min_k - E && (rich || (c <= E && m <= E && y <= E)), + Color::Gray { k } => k >= min_k - E, + Color::Rgb { .. } => false, + } +} + +/// Edit → Edit Colors → Overprint Black: which fills and strokes count as black, and whether +/// they start or stop overprinting. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct OverprintBlack { + /// Remove overprinting instead of adding it. + pub remove: bool, + /// Minimum black ink, 0..1. + pub min_k: f32, + pub fill: bool, + pub stroke: bool, + /// Include Blacks with CMY: rich blacks count too. + pub rich: bool, + /// Include Spot Blacks: colours linked to a spot swatch count too. + pub spot: bool, +} + +impl Default for OverprintBlack { + fn default() -> Self { + Self { remove: false, min_k: 1.0, fill: true, stroke: true, rich: false, spot: false } + } +} + +impl OverprintBlack { + /// Whether `p` is black: a solid colour (linked to one of the spot swatches `spots` only with + /// [`Self::spot`]), or a gradient whose every stop is. + fn black(&self, p: &Paint, spots: &[String]) -> bool { + match p { + Paint::Solid { color, swatch } => { + (self.spot || !swatch.as_ref().is_some_and(|s| spots.contains(s))) && is_black_ink(color, self.min_k, self.rich) + } + Paint::Gradient(g) => !g.gradient.stops.is_empty() && g.gradient.stops.iter().all(|s| is_black_ink(&s.color, self.min_k, self.rich)), + _ => false, + } + } + + /// Set (or clear) the overprint of `n`'s own black fills and strokes and its characters'. + /// `spots`: the document's spot swatch names ([`Document::spot_names`]). Returns whether + /// anything changed. + pub fn apply(&self, n: &mut Node, spots: &[String]) -> bool { + let on = !self.remove; + let mut changed = false; + let mut set = |flag: &mut bool| { + changed |= *flag != on; + *flag = on; + }; + for it in &mut n.appearance.items { + if (if it.is_fill() { self.fill } else { self.stroke }) && self.black(it.paint(), spots) { + set(it.overprint_mut()); + } + } + if let NodeKind::Text(t) = &mut n.kind { + for r in &mut t.runs { + if self.fill && self.black(&r.style.fill, spots) { + set(&mut r.style.overprint_fill); + } + if self.stroke && self.black(&r.style.stroke, spots) { + set(&mut r.style.overprint_stroke); + } + } + } + changed + } +} + +impl Node { + /// Whether any fill or stroke of this subtree overprints (characters' aside). + pub fn has_overprint(&self) -> bool { + self.appearance.items.iter().any(|i| i.overprint()) || self.children().is_some_and(|c| c.iter().any(|c| c.has_overprint())) + } +} + +impl Document { + /// The names of the spot swatches. + pub fn spot_names(&self) -> Vec { + self.swatches_iter().filter(|s| s.spot).map(|s| s.name.clone()).collect() + } + + /// Files from before per-fill/stroke overprint listed the objects marked Overprint Black + /// under [`LEGACY_OVERPRINT_KEY`]: their 100% black fills and strokes overprint now. + pub fn migrate_overprint_black(&mut self) { + let Some(list) = self.unknown.remove(LEGACY_OVERPRINT_KEY) else { return }; + let ids: Vec = list.as_array().into_iter().flatten().filter_map(|v| v.as_u64()).map(NodeId).collect(); + // The old list marked spot-linked blacks too (so the spot names don't matter). + let op = OverprintBlack { spot: true, ..Default::default() }; + for id in ids { + if let Some(n) = self.node_mut(id) { + op.apply(n, &[]); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{Appearance, CharStyle, TextObject}; + use vectorcraft_geom::Point; + + fn cmyk(c: f32, m: f32, y: f32, k: f32) -> Paint { + Paint::solid(Color::cmyk(c, m, y, k)) + } + + #[test] + fn black_ink_rules() { + assert!(is_black_ink(&Color::cmyk(0.0, 0.0, 0.0, 1.0), 1.0, false)); + assert!(!is_black_ink(&Color::cmyk(0.6, 0.4, 0.4, 1.0), 1.0, false), "rich black needs `rich`"); + assert!(is_black_ink(&Color::cmyk(0.6, 0.4, 0.4, 1.0), 1.0, true)); + assert!(is_black_ink(&Color::cmyk(0.0, 0.0, 0.0, 0.8), 0.75, false) && !is_black_ink(&Color::cmyk(0.0, 0.0, 0.0, 0.8), 0.9, false)); + assert!(is_black_ink(&Color::gray(1.0), 1.0, false)); + assert!(!is_black_ink(&Color::rgb(0.0, 0.0, 0.0), 1.0, true), "RGB black is not ink"); + } + + #[test] + fn apply_covers_items_runs_gradients_and_spots() { + let k = cmyk(0.0, 0.0, 0.0, 1.0); + let mut n = Node::path(NodeId(1), Default::default(), Appearance::basic(k.clone(), k.clone(), 1.0)); + let op = OverprintBlack { fill: false, ..Default::default() }; + assert!(op.apply(&mut n, &[])); + assert!(!n.appearance.fill().unwrap().overprint && n.appearance.stroke().unwrap().overprint, "fill: false marks strokes only"); + assert!(!op.apply(&mut n, &[]), "nothing left to change"); + assert!(OverprintBlack { remove: true, ..Default::default() }.apply(&mut n, &[])); + assert!(!n.has_overprint()); + + // Gradients count when every stop is black. + let mut g = vectorcraft_color::GradientPaint::new(Default::default()); + for s in &mut g.gradient.stops { + s.color = Color::cmyk(0.0, 0.0, 0.0, 1.0); + } + n.appearance.set_fill(Paint::Gradient(Box::new(g.clone()))); + assert!(OverprintBlack::default().apply(&mut n, &[]) && n.appearance.fill().unwrap().overprint); + g.gradient.stops[0].color = Color::cmyk(0.0, 0.0, 0.0, 0.0); + let fill = n.appearance.fill_mut().unwrap(); + fill.paint = Paint::Gradient(Box::new(g)); + fill.overprint = false; + assert!(!OverprintBlack::default().apply(&mut n, &[]), "the stroke was marked already"); + assert!(!n.appearance.fill().unwrap().overprint, "a gradient with a white stop is not black"); + + // Spot-linked blacks only with `spot`; type runs too. + let spot = Paint::Solid { color: Color::cmyk(0.0, 0.0, 0.0, 1.0), swatch: Some("Ink".into()) }; + let style = CharStyle { fill: spot, stroke: k, stroke_width: 1.0, ..CharStyle::default() }; + let mut t = Node::new(NodeId(2), NodeKind::Text(Box::new(TextObject::point(Point::ZERO, "Hi", style)))); + OverprintBlack::default().apply(&mut t, &["Ink".to_string()]); + let NodeKind::Text(tx) = &t.kind else { unreachable!() }; + assert!(!tx.runs[0].style.overprint_fill && tx.runs[0].style.overprint_stroke); + OverprintBlack { spot: true, ..Default::default() }.apply(&mut t, &["Ink".to_string()]); + let NodeKind::Text(tx) = &t.kind else { unreachable!() }; + assert!(tx.runs[0].style.overprint_fill); + } + + #[test] + fn legacy_overprint_black_list_migrates() { + let mut d = Document::new(100.0, 100.0); + let layer = d.default_layer().unwrap(); + let k = cmyk(0.0, 0.0, 0.0, 1.0); + let a = d.alloc_id(); + d.insert(Some(layer), usize::MAX, Node::path(a, Default::default(), Appearance::basic(k.clone(), cmyk(1.0, 0.0, 0.0, 0.0), 1.0))).unwrap(); + let b = d.alloc_id(); + d.insert(Some(layer), usize::MAX, Node::path(b, Default::default(), Appearance::basic(k, Paint::None, 1.0))).unwrap(); + d.unknown.insert(LEGACY_OVERPRINT_KEY.into(), serde_json::json!([a.0])); + d.migrate_overprint_black(); + assert!(!d.unknown.contains_key(LEGACY_OVERPRINT_KEY)); + let na = d.node(a).unwrap(); + assert!(na.appearance.fill().unwrap().overprint && !na.appearance.stroke().unwrap().overprint, "the black fill, not the cyan stroke"); + assert!(!d.node(b).unwrap().has_overprint(), "objects not listed keep knocking out"); + } + + #[test] + fn overprint_round_trips_and_old_layers_load() { + let mut f = crate::FillLayer::new(Paint::None); + assert!(!serde_json::to_string(&f).unwrap().contains("overprint"), "off is not written"); + f.overprint = true; + let back: crate::FillLayer = serde_json::from_str(&serde_json::to_string(&f).unwrap()).unwrap(); + assert!(back.overprint); + let old: crate::StrokeLayer = serde_json::from_str(r#"{"paint":{"type":"none"},"width":2.0}"#).unwrap(); + assert!(!old.overprint); + let st = CharStyle { overprint_stroke: true, ..CharStyle::default() }; + let back: CharStyle = serde_json::from_str(&serde_json::to_string(&st).unwrap()).unwrap(); + assert!(back.overprint_stroke && !back.overprint_fill); + } +} diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index 89d9138a..af75a976 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -59,6 +59,11 @@ pub struct CharStyle { /// Character style (Character Styles panel) these attributes come from; None = Normal. #[serde(default, skip_serializing_if = "Option::is_none")] pub style_name: Option, + /// Overprint Fill / Overprint Stroke of these characters (see [`crate::FillLayer::overprint`]). + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + pub overprint_fill: bool, + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + pub overprint_stroke: bool, } fn regular() -> String { @@ -89,6 +94,8 @@ impl Default for CharStyle { all_caps: false, features: vec![], style_name: None, + overprint_fill: false, + overprint_stroke: false, } } } @@ -97,6 +104,19 @@ impl CharStyle { pub fn effective_leading(&self) -> f64 { self.leading.unwrap_or(self.size * 1.2) } + /// These characters' paint as an object appearance (their outlines'): the fill, plus the + /// stroke when it is painted, overprinting as the characters do. + pub fn appearance(&self) -> crate::Appearance { + let mut fill = crate::FillLayer::new(self.fill.clone()); + fill.overprint = self.overprint_fill; + let mut a = crate::Appearance { items: vec![crate::AppearanceItem::Fill(fill)], effects: vec![] }; + if !self.stroke.is_none() && self.stroke_width > 0.0 { + let mut st = crate::StrokeLayer::new(self.stroke.clone(), self.stroke_width); + st.overprint = self.overprint_stroke; + a.items.push(crate::AppearanceItem::Stroke(st)); + } + a + } } /// Tab stop alignment (Tabs panel). diff --git a/crates/engine/src/cmd/colormgmt.rs b/crates/engine/src/cmd/colormgmt.rs index e06b0225..9b3378fc 100644 --- a/crates/engine/src/cmd/colormgmt.rs +++ b/crates/engine/src/cmd/colormgmt.rs @@ -12,7 +12,7 @@ use serde_json::{Value, json}; use vectorcraft_color::cms::{self, ColorSettings, Intent, Model, ProofTarget}; use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::ColorMode; -use vectorcraft_render::proof::{self, OVERPRINT_KEY}; +use vectorcraft_render::proof; use super::*; @@ -111,9 +111,9 @@ pub fn specs() -> Vec { "Overprint Black…", ["Edit", "Edit Colors"], None, - "{remove?: false, percentage?: 100, ids?} mark (or unmark) objects whose fill or stroke is black-only ink ≥ percentage as overprinting (Overprint Preview, separations) → {changed}", + "{remove?: false, percentage?: 100, ids?} make the black fills and strokes (black-only ink ≥ percentage) of the selection (or ids; groups: their contents) overprint, or stop (remove) → {changed: objects}", has_doc, - overprint_black + super::overprint::overprint_black ), cmd!( "swatch.setSpot", @@ -433,55 +433,6 @@ fn separations(s: &mut Session, p: &Value) -> Result { Ok(plates_json(s)) } -fn overprint_black(s: &mut Session, p: &Value) -> Result { - let remove = bool_or(p, "remove", false); - let pct = (f64_or(p, "percentage", 100.0) / 100.0).clamp(0.0, 1.0) as f32; - let ids = targets(s, p)?; - let doc = &s.doc()?.doc; - let black_only = |c: &Color| match *c { - Color::Cmyk { c, m, y, k } => k >= pct - 0.005 && c <= 0.005 && m <= 0.005 && y <= 0.005, - Color::Gray { k } => k >= pct - 0.005, - Color::Rgb { .. } => false, - }; - let mut hits: Vec = vec![]; - for id in &ids { - if let Some(n) = doc.node(*id) { - n.walk(&mut |m: &vectorcraft_doc::Node| { - let bl = m.appearance.items.iter().any(|it| match it { - vectorcraft_doc::AppearanceItem::Fill(l) => l.paint.color().is_some_and(|c| black_only(&c)), - vectorcraft_doc::AppearanceItem::Stroke(l) => l.paint.color().is_some_and(|c| black_only(&c)), - }); - if bl { - hits.push(m.id.0); - } - }); - } - } - let mut list: Vec = proof::overprint_ids(doc).into_iter().map(|i| i.0).collect(); - let before = list.clone(); - if remove { - list.retain(|i| !hits.contains(i)); - } else { - for h in &hits { - if !list.contains(h) { - list.push(*h); - } - } - } - let changed = if remove { before.len() - list.len() } else { list.len() - before.len() }; - if changed > 0 { - s.edit("Overprint Black", |d, _| { - if list.is_empty() { - d.unknown.remove(OVERPRINT_KEY); - } else { - d.unknown.insert(OVERPRINT_KEY.into(), json!(list)); - } - Ok(()) - })?; - } - Ok(json!({ "changed": changed })) -} - fn set_spot(s: &mut Session, p: &Value) -> Result { const C: &str = "swatch.setSpot"; let name = str_param(p, "name").ok_or_else(|| bad(C, "missing `name`"))?.to_string(); diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 866e1011..59c83b10 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -24,6 +24,7 @@ pub(crate) mod maskedit; pub(crate) mod menucmds; mod object; mod opacitymask; +mod overprint; mod paint; mod panelcmds; mod path; @@ -192,6 +193,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(prefscmds::specs()); v.extend(distortcmds::specs()); v.extend(paint::proxy_specs()); + v.extend(overprint::specs()); v }) } @@ -349,3 +351,6 @@ pub use opacitymask::TransparencyInfo; /// What the Eyedropper copies (`Session::eyedropper`). pub use xform::EyedropperOptions; + +/// The Attributes panel's state ([`Session::attributes_info`]). +pub use overprint::AttributesInfo; diff --git a/crates/engine/src/cmd/overprint.rs b/crates/engine/src/cmd/overprint.rs new file mode 100644 index 00000000..60fda0cf --- /dev/null +++ b/crates/engine/src/cmd/overprint.rs @@ -0,0 +1,203 @@ +//! Overprinting (Attributes panel → Overprint Fill / Overprint Stroke, Edit → Edit Colors → +//! Overprint Black): each fill and stroke, and each character's fill and stroke, can overprint +//! ([`vectorcraft_doc::FillLayer::overprint`]). Overprint Preview and Separations Preview show it. + +use std::collections::BTreeSet; +use std::sync::Arc; + +use serde_json::{Value, json}; +use vectorcraft_doc::overprint::OverprintBlack; +use vectorcraft_doc::{Document, Node, NodeId, NodeKind}; + +use super::appearance::{ItemTarget, item_target}; +use super::edit::selected_roots; +use super::*; + +pub fn specs() -> Vec { + vec![ + cmd!( + "object.setOverprint", + "Overprint", + [], + None, + "{fill?: bool, stroke?: bool, item?: appearance item index|null, ids?} make fills and/or strokes overprint (Overprint Preview, Separations Preview) or knock out: item's, else every fill or stroke of the painted objects (groups: their contents; type: its characters too). Without item and ids, the Appearance panel's active item stands in when it is of that kind → {changed: objects}", + has_doc, + set_overprint + ), + cmd!( + query "attributes.info", + "Attributes", + [], + None, + "{ids?, item?} → {ids, overprintFill, overprintStroke} the Attributes panel's values for `ids` or the selection, aimed as object.setOverprint aims; a value is null where they differ (or nothing has a fill or stroke)", + has_doc, + |s, p| Ok(attributes(s, p)?.to_json()) + ), + ] +} + +/// The Attributes panel's values ([`Session::attributes_info`], `attributes.info`). +#[derive(Clone, Debug, Default, PartialEq)] +pub struct AttributesInfo { + /// The targets (the selection by default). + pub ids: Vec, + /// Overprint Fill; `None` where the targets differ or have no fill. + pub overprint_fill: Option, + /// Overprint Stroke; `None` where the targets differ or have no stroke. + pub overprint_stroke: Option, +} + +impl AttributesInfo { + fn to_json(&self) -> Value { + json!({ + "ids": self.ids.iter().map(|id| id.0).collect::>(), + "overprintFill": self.overprint_fill, + "overprintStroke": self.overprint_stroke, + }) + } +} + +impl Session { + /// The Attributes panel's values for the selection. + pub fn attributes_info(&self) -> AttributesInfo { + attributes(self, &Value::Null).unwrap_or_default() + } +} + +/// The overprint flags of `n` a fill (`fill`) or stroke edit aimed at item `index` covers: that +/// item's, or (`None`) every fill's or stroke's and, on type, its characters'. +fn flags(n: &Node, fill: bool, index: Option) -> Vec { + let mut v: Vec = n + .appearance + .items + .iter() + .enumerate() + .filter(|(i, it)| index.map_or(it.is_fill() == fill, |x| x == *i)) + .map(|(_, it)| it.overprint()) + .collect(); + if let (None, NodeKind::Text(t)) = (index, &n.kind) { + v.extend(t.runs.iter().map(|r| if fill { r.style.overprint_fill } else { r.style.overprint_stroke })); + } + v +} + +/// [`flags`], to change. +fn flags_mut(n: &mut Node, fill: bool, index: Option) -> Vec<&mut bool> { + let mut v: Vec<&mut bool> = n + .appearance + .items + .iter_mut() + .enumerate() + .filter(|(i, it)| index.map_or(it.is_fill() == fill, |x| x == *i)) + .map(|(_, it)| it.overprint_mut()) + .collect(); + if let (None, NodeKind::Text(t)) = (index, &mut n.kind) { + v.extend(t.runs.iter_mut().map(|r| if fill { &mut r.style.overprint_fill } else { &mut r.style.overprint_stroke })); + } + v +} + +/// The `fill` and `stroke` params given: (fill?, on). +fn kinds(p: &Value, cmd: &str) -> Result> { + [(true, "fill"), (false, "stroke")] + .into_iter() + .filter_map(|(fill, key)| { + p.get(key).map(|v| v.as_bool().map(|on| (fill, on)).ok_or_else(|| bad(cmd, format!("`{key}` must be true or false")))) + }) + .collect() +} + +/// Each kind's item target and objects: (fill?, item, ids). +fn aims(s: &Session, p: &Value, cmd: &str, kinds: impl IntoIterator) -> Result)>> { + let item = item_target(s, p, cmd)?; + kinds + .into_iter() + .map(|fill| { + let it = item.of_kind(s, fill); + Ok((fill, it, it.targets(s, p)?)) + }) + .collect() +} + +/// Run `f` on a copy of the document and commit it as one undo step (`label`) only when `f` +/// reports changes; returns their count. +fn edit_counted(s: &mut Session, label: &str, f: impl FnOnce(&mut Document) -> Result) -> Result { + let mut d = (*s.doc()?.doc).clone(); + let n = f(&mut d)?; + if n > 0 { + s.edit(label, |doc, _| { + *doc = d; + Ok(()) + })?; + } + Ok(n) +} + +fn set_overprint(s: &mut Session, p: &Value) -> Result { + const C: &str = "object.setOverprint"; + let kinds = kinds(p, C)?; + if kinds.is_empty() { + return Err(bad(C, "give `fill` and/or `stroke`")); + } + let jobs: Vec<_> = aims(s, p, C, kinds.iter().map(|k| k.0))?.into_iter().zip(kinds.iter().map(|k| k.1)).collect(); + let changed = edit_counted(s, "Overprint", |d| { + let mut changed = BTreeSet::new(); + for ((fill, item, ids), on) in &jobs { + for id in ids { + let Some(n) = d.node_mut(*id) else { continue }; + let index = item.resolve(&n.appearance, *fill, C)?; + for f in flags_mut(n, *fill, index) { + if *f != *on { + *f = *on; + changed.insert(*id); + } + } + } + } + Ok(changed.len()) + })?; + Ok(json!({ "changed": changed })) +} + +fn attributes(s: &Session, p: &Value) -> Result { + const C: &str = "attributes.info"; + let d = &s.doc()?.doc; + let mut info = AttributesInfo { ids: targets(s, p)?, ..Default::default() }; + for (fill, item, ids) in aims(s, p, C, [true, false])? { + let mut all = + ids.iter().filter_map(|id| d.node(*id)).filter_map(|n| Some(flags(n, fill, item.resolve(&n.appearance, fill, C).ok()?))).flatten(); + let same = all.next().and_then(|first| all.all(|b| b == first).then_some(first)); + if fill { + info.overprint_fill = same; + } else { + info.overprint_stroke = same; + } + } + Ok(info) +} + +/// Overprint Black on `n` and its descendants; returns the objects changed. +fn overprint_black_tree(n: &mut Node, op: &OverprintBlack, spots: &[String]) -> usize { + let mut changed = usize::from(op.apply(n, spots)); + for c in n.children_mut().into_iter().flatten() { + changed += overprint_black_tree(Arc::make_mut(c), op, spots); + } + changed +} + +pub(crate) fn overprint_black(s: &mut Session, p: &Value) -> Result { + let op = OverprintBlack { + remove: bool_or(p, "remove", false), + min_k: (f64_or(p, "percentage", 100.0) / 100.0).clamp(0.0, 1.0) as f32, + ..Default::default() + }; + let ids = match ids_param(p, "ids") { + Some(v) => v, + None => selected_roots(s)?, + }; + let changed = edit_counted(s, "Overprint Black", |d| { + let spots = d.spot_names(); + Ok(ids.iter().map(|id| d.node_mut(*id).map_or(0, |n| overprint_black_tree(n, &op, &spots))).sum()) + })?; + Ok(json!({ "changed": changed })) +} diff --git a/crates/engine/src/cmd/pathops.rs b/crates/engine/src/cmd/pathops.rs index 5e4b6b5c..8dce208e 100644 --- a/crates/engine/src/cmd/pathops.rs +++ b/crates/engine/src/cmd/pathops.rs @@ -5,7 +5,7 @@ use std::sync::Arc; use serde_json::{Value, json}; -use vectorcraft_color::{BlendMode, Paint}; +use vectorcraft_color::BlendMode; use vectorcraft_doc::appearance::{AppearanceItem, StrokeLayer}; use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, TextObject}; use vectorcraft_geom::{FillRule, PathData}; @@ -227,21 +227,9 @@ pub(crate) fn text_outline(t: &TextObject) -> PathData { PathData::from_bezpath(&lay.to_bezpath()).transformed(t.xf) } -/// Appearance a text object's outlines take: the first run's fill and stroke. -pub(crate) fn text_style_appearance(fill: &Paint, stroke: &Paint, width: f64) -> Appearance { - let mut a = Appearance { items: vec![AppearanceItem::Fill(vectorcraft_doc::appearance::FillLayer::new(fill.clone()))], effects: vec![] }; - if !stroke.is_none() && width > 0.0 { - a.items.push(AppearanceItem::Stroke(StrokeLayer::new(stroke.clone(), width))); - } - a -} - fn style_of(n: &Node) -> Style { match &n.kind { - NodeKind::Text(t) => { - let st = t.first_style(); - Style { appearance: text_style_appearance(&st.fill, &st.stroke, st.stroke_width), opacity: n.opacity, blend: n.blend } - } + NodeKind::Text(t) => Style { appearance: t.first_style().appearance(), opacity: n.opacity, blend: n.blend }, _ => Style::of(n), } } @@ -492,6 +480,7 @@ fn outlined_stroke(d: &mut Document, brushes: &[vectorcraft_brush::Brush], path: opacity: st.opacity, blend: st.blend, effects: st.effects.clone(), + overprint: st.overprint, ..vectorcraft_doc::appearance::FillLayer::new(st.paint.clone()) }; n.appearance = Appearance { items: vec![AppearanceItem::Fill(fill)], effects: vec![] }; diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index 549609bf..8bc1a935 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -8,7 +8,7 @@ use vectorcraft_doc::{Document, Justify, Node, NodeId, NodeKind, TextObject}; use vectorcraft_geom::PathData; use super::edit::selected_roots; -use super::pathops::{num_param, shape_node, text_style_appearance}; +use super::pathops::{num_param, shape_node}; use super::*; pub fn specs() -> Vec { @@ -103,7 +103,7 @@ fn create_outlines(s: &mut Session, _: &Value) -> Result { } let st = t.runs.get(g.run).map(|r| r.style.clone()).unwrap_or_else(|| t.first_style()); let mut node = shape_node(d, path, None); - node.appearance = text_style_appearance(&st.fill, &st.stroke, st.stroke_width); + node.appearance = st.appearance(); children.push(Arc::new(node)); } let (par, idx, _) = d.position(tid).ok_or(EngineError::NoNode(tid))?; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index f5247955..88e86f1c 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -774,6 +774,8 @@ mod tests_opacitymask; #[cfg(test)] mod tests_outlinestroke; #[cfg(test)] +mod tests_overprint; +#[cfg(test)] mod tests_paintproxy; #[cfg(test)] mod tests_panelcmds; diff --git a/crates/engine/src/tests_colormgmt.rs b/crates/engine/src/tests_colormgmt.rs index 42906c58..15b20411 100644 --- a/crates/engine/src/tests_colormgmt.rs +++ b/crates/engine/src/tests_colormgmt.rs @@ -298,7 +298,7 @@ fn overprint_black_multiplies_in_preview() { assert!(over[2] as i32 > over[0] as i32 + 10, "overprint lets the cyan through: {over:?}"); assert!(over[2] < knock[2] + 60 && over[2] <= 128, "and stays dark: {over:?}"); s.execute("edit.colors.overprintBlack", &json!({"remove": true})).unwrap(); - assert!(vectorcraft_render::proof::overprint_ids(&s.doc().unwrap().doc).is_empty()); + assert!(!s.doc().unwrap().doc.node(k).unwrap().has_overprint()); } #[test] diff --git a/crates/engine/src/tests_overprint.rs b/crates/engine/src/tests_overprint.rs new file mode 100644 index 00000000..81570e6f --- /dev/null +++ b/crates/engine/src/tests_overprint.rs @@ -0,0 +1,194 @@ +//! Overprint per fill and stroke (`object.setOverprint`, `attributes.info`), its preview, the +//! legacy Overprint Black list, and Edit → Edit Colors → Overprint Black. + +use serde_json::{Value, json}; +use vectorcraft_doc::{Document, Node, NodeKind}; +use vectorcraft_geom::Affine; +use vectorcraft_render::{RenderOptions, Renderer}; + +use super::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + s +} + +/// A CMYK colour param (percentages). +fn ink(c: u8, m: u8, y: u8, k: u8) -> Value { + json!({"c": c, "m": m, "y": y, "k": k}) +} +fn cyan_ink() -> Value { + ink(100, 0, 0, 0) +} +fn yellow_ink() -> Value { + ink(0, 0, 100, 0) +} +fn black_ink() -> Value { + ink(0, 0, 0, 100) +} + +/// A rectangle with this fill and stroke (`None`: no stroke) of weight `weight`. +fn rect(s: &mut Session, xywh: [f64; 4], fill: Value, stroke: Option, weight: f64) -> NodeId { + let [x, y, width, height] = xywh; + let id = NodeId(s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": width, "height": height})).unwrap()["id"].as_u64().unwrap()); + s.execute("paint.setFill", &json!({"ids": [id.0], "color": fill})).unwrap(); + match stroke { + Some(c) => { + s.execute("paint.setStroke", &json!({"ids": [id.0], "color": c})).unwrap(); + s.execute("stroke.set", &json!({"ids": [id.0], "weight": weight})).unwrap(); + } + None => { + s.execute("paint.setStroke", &json!({"ids": [id.0], "none": true})).unwrap(); + } + } + id +} + +fn pixel(doc: &Document, preview: bool, x: u32, y: u32) -> [u8; 4] { + let mut r = Renderer::new(); + r.threads = 0; + let opts = RenderOptions { background: Some([255, 255, 255, 255]), overprint_preview: preview, ..Default::default() }; + r.render(doc, 100, 100, Affine::IDENTITY, &opts).pixel(x, y) +} + +fn green(p: [u8; 4]) -> bool { + p[1] as i32 > p[0] as i32 + 60 && p[1] as i32 > p[2] as i32 + 60 +} + +fn cyan(p: [u8; 4]) -> bool { + p[2] as i32 > p[0] as i32 + 60 && p[2] > 150 && !green(p) +} + +fn node(s: &Session, id: NodeId) -> &Node { + s.doc().unwrap().doc.node(id).unwrap() +} + +fn undo_len(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() +} + +#[test] +fn cyan_stroke_overprinting_yellow_previews_green_under_the_stroke_only() { + let mut s = session(); + rect(&mut s, [0.0, 0.0, 100.0, 100.0], yellow_ink(), None, 0.0); + let c = rect(&mut s, [20.0, 20.0, 60.0, 60.0], cyan_ink(), Some(cyan_ink()), 10.0); + let r = s.execute("object.setOverprint", &json!({"stroke": true, "ids": [c.0]})).unwrap(); + assert_eq!(r["changed"], 1); + let a = node(&s, c).appearance.clone(); + assert!(a.stroke().unwrap().overprint && !a.fill().unwrap().overprint); + + let doc = s.doc().unwrap().doc.clone(); + // The outer half of the stroke lies over yellow only; the fill knocks out. + let (stroke, fill) = ((17, 50), (50, 50)); + assert!(cyan(pixel(&doc, false, stroke.0, stroke.1)), "no preview: the stroke knocks out {:?}", pixel(&doc, false, stroke.0, stroke.1)); + assert!(green(pixel(&doc, true, stroke.0, stroke.1)), "overprint preview: cyan over yellow {:?}", pixel(&doc, true, stroke.0, stroke.1)); + assert!(cyan(pixel(&doc, true, fill.0, fill.1)), "the fill still knocks out {:?}", pixel(&doc, true, fill.0, fill.1)); + // Separations Preview implies overprint preview. + let mut r = Renderer::new(); + r.threads = 0; + let seps = vectorcraft_render::proof::ProofSetup { separations: Some(vec!["Yellow".into()]), ..Default::default() }; + let opts = RenderOptions { background: Some([255, 255, 255, 255]), proof: Some(seps), ..Default::default() }; + let px = r.render(&doc, 100, 100, Affine::IDENTITY, &opts).pixel(stroke.0, stroke.1); + assert!(px[0] < 100, "the yellow plate keeps its ink under the overprinting stroke: {px:?}"); + let px = r.render(&doc, 100, 100, Affine::IDENTITY, &opts).pixel(fill.0, fill.1); + assert!(px[0] > 200, "and is knocked out under the fill: {px:?}"); +} + +#[test] +fn set_overprint_is_one_undo_step_and_reports_through_attributes_info() { + let mut s = session(); + let a = rect(&mut s, [0.0, 0.0, 10.0, 10.0], cyan_ink(), Some(black_ink()), 1.0); + let b = rect(&mut s, [20.0, 0.0, 10.0, 10.0], cyan_ink(), Some(black_ink()), 1.0); + s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap(); + let info = s.execute("attributes.info", &json!({})).unwrap(); + assert_eq!((info["overprintFill"].clone(), info["overprintStroke"].clone()), (json!(false), json!(false))); + assert_eq!(info["ids"], json!([a.0, b.0])); + + let before = undo_len(&s); + s.execute("object.setOverprint", &json!({"fill": true, "stroke": true, "ids": [a.0]})).unwrap(); + assert_eq!(undo_len(&s), before + 1, "fill and stroke in one step"); + let info = s.execute("attributes.info", &json!({})).unwrap(); + assert_eq!((info["overprintFill"].clone(), info["overprintStroke"].clone()), (Value::Null, Value::Null), "mixed"); + assert_eq!(s.attributes_info().overprint_fill, None); + let info = s.execute("attributes.info", &json!({"ids": [a.0]})).unwrap(); + assert_eq!((info["overprintFill"].clone(), info["overprintStroke"].clone()), (json!(true), json!(true))); + + let r = s.execute("object.setOverprint", &json!({"fill": true, "ids": [a.0]})).unwrap(); + assert_eq!((r["changed"].clone(), undo_len(&s)), (json!(0), before + 1), "no change, no undo step"); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(!node(&s, a).has_overprint()); + + assert!(s.execute("object.setOverprint", &json!({"ids": [a.0]})).is_err(), "needs fill or stroke"); + assert!(s.execute("object.setOverprint", &json!({"fill": "yes", "ids": [a.0]})).is_err()); +} + +#[test] +fn set_overprint_reaches_groups_items_and_type() { + let mut s = session(); + let a = rect(&mut s, [0.0, 0.0, 10.0, 10.0], cyan_ink(), Some(black_ink()), 1.0); + let b = rect(&mut s, [20.0, 0.0, 10.0, 10.0], cyan_ink(), None, 0.0); + s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap(); + let g = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap()); + s.execute("object.setOverprint", &json!({"fill": true, "ids": [g.0]})).unwrap(); + assert!(node(&s, a).appearance.fill().unwrap().overprint && node(&s, b).appearance.fill().unwrap().overprint, "a group's contents"); + + // An explicit appearance item: only that one. + s.execute("appearance.addFill", &json!({"ids": [a.0]})).unwrap(); + let items = node(&s, a).appearance.items.len(); + let fills: Vec = (0..items).filter(|i| node(&s, a).appearance.items[*i].is_fill()).collect(); + s.execute("object.setOverprint", &json!({"fill": false, "ids": [a.0]})).unwrap(); + s.execute("object.setOverprint", &json!({"fill": true, "item": fills[0], "ids": [a.0]})).unwrap(); + let ov: Vec = fills.iter().map(|i| node(&s, a).appearance.items[*i].overprint()).collect(); + assert_eq!(ov, [true, false]); + let stroke = (0..items).find(|i| !node(&s, a).appearance.items[*i].is_fill()).unwrap(); + assert!(s.execute("object.setOverprint", &json!({"fill": true, "item": stroke, "ids": [a.0]})).is_err(), "not a fill"); + + // Type: its characters. + let t = NodeId(s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Hi"})).unwrap()["id"].as_u64().unwrap()); + s.execute("object.setOverprint", &json!({"fill": true, "ids": [t.0]})).unwrap(); + let NodeKind::Text(tx) = &node(&s, t).kind else { panic!() }; + assert!(tx.runs.iter().all(|r| r.style.overprint_fill && !r.style.overprint_stroke)); + assert_eq!(s.execute("attributes.info", &json!({"ids": [t.0]})).unwrap()["overprintFill"], true); +} + +#[test] +fn overprinting_type_previews_through() { + let mut s = session(); + rect(&mut s, [0.0, 0.0, 100.0, 100.0], yellow_ink(), None, 0.0); + let t = NodeId(s.execute("text.create", &json!({"x": 5, "y": 90, "text": "H", "size": 90})).unwrap()["id"].as_u64().unwrap()); + s.execute("paint.setFill", &json!({"ids": [t.0], "color": cyan_ink()})).unwrap(); + let doc = s.doc().unwrap().doc.clone(); + let b = vectorcraft_render::Renderer::new().render(&doc, 100, 100, Affine::IDENTITY, &RenderOptions::default()); + // A pixel the glyph covers. + let (x, y) = (0..100u32).flat_map(|y| (0..100u32).map(move |x| (x, y))).find(|&(x, y)| cyan(b.pixel(x, y))).expect("the glyph draws"); + s.execute("object.setOverprint", &json!({"fill": true, "ids": [t.0]})).unwrap(); + let doc = s.doc().unwrap().doc.clone(); + assert!(cyan(pixel(&doc, false, x, y)) && green(pixel(&doc, true, x, y)), "{:?}", pixel(&doc, true, x, y)); +} + +#[test] +fn old_documents_with_an_overprint_black_list_still_preview() { + let mut s = session(); + rect(&mut s, [0.0, 0.0, 100.0, 100.0], cyan_ink(), None, 0.0); + let k = rect(&mut s, [25.0, 25.0, 50.0, 50.0], black_ink(), Some(cyan_ink()), 1.0); + // How older versions stored Overprint Black. + let mut old = (*s.doc().unwrap().doc).clone(); + old.unknown.insert("overprintBlack".into(), json!([k.0])); + let bytes = vectorcraft_format::save(&old, false); + let text = String::from_utf8(bytes.clone()).unwrap(); + assert!(text.contains("\"overprintBlack\"") && !text.contains("\"overprint\"")); + + let doc = vectorcraft_format::load(&bytes).unwrap(); + assert!(!doc.unknown.contains_key("overprintBlack"), "migrated on load"); + let n = doc.node(k).unwrap(); + assert!(n.appearance.fill().unwrap().overprint && !n.appearance.stroke().unwrap().overprint, "the black fill, not the cyan stroke"); + let over = pixel(&doc, true, 50, 50); + assert!(over[2] as i32 > over[0] as i32 + 10 && over[2] <= 128, "black overprints the cyan: {over:?}"); + let knock = pixel(&doc, false, 50, 50); + assert!((knock[0] as i32 - knock[2] as i32).abs() < 8, "knockout without the preview: {knock:?}"); + + // The flags round-trip through the native format. + let back = vectorcraft_format::load(&vectorcraft_format::save(&doc, false)).unwrap(); + assert_eq!(back.node(k).unwrap().appearance, n.appearance); +} diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index e0120fb4..20fa8817 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -99,6 +99,8 @@ pub fn load(bytes: &[u8]) -> Result { } // Saved mid-edit by an older version: drop the opacity-mask editing layer. doc.drop_edit_modes(); + // Saved before per-fill/stroke overprint: the Overprint Black list becomes overprint flags. + doc.migrate_overprint_black(); doc.fix_next_id(); Ok(doc) } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index bf056500..434a23b1 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -877,6 +877,14 @@ impl Renderer { return; } let tb = t.xf.transform_rect_bbox(g.bounds); + // Overprinting characters multiply per draw, like overprinting fills and strokes + // ([`proof`]); the object blend mode is Normal here or applied by an enclosing layer. + let overprints = proof::overprints(f.opts); + let overprint = |ctx: &mut RenderContext, on: bool| { + if overprints { + ctx.set_blend_mode(if on { blend_mode(vectorcraft_color::BlendMode::Multiply) } else { BlendMode::default() }); + } + }; // Object-level appearance fills/strokes apply on top of character fills (like Illustrator). for (i, run) in t.runs.iter().enumerate() { let Some(path) = g.runs.get(i) else { continue }; @@ -887,16 +895,19 @@ impl Renderer { ctx.set_fill_rule(peniko::Fill::NonZero); if paint::set_paint(ctx, &run.style.fill, g.bounds, f.doc) { self.fold_alpha(ctx, &run.style.fill); + overprint(ctx, run.style.overprint_fill); ctx.fill_path(path); } if !run.style.stroke.is_none() && run.style.stroke_width > 0.0 && paint::set_paint(ctx, &run.style.stroke, stroke_paint_bounds(g.bounds, run.style.stroke_width), f.doc) { + overprint(ctx, run.style.overprint_stroke); ctx.set_stroke(kurbo::Stroke::new(run.style.stroke_width)); ctx.stroke_path(path); } } + overprint(ctx, false); if !n.appearance.items.is_empty() { let mut all = g.all.clone(); all.apply_affine(t.xf); diff --git a/crates/render/src/live.rs b/crates/render/src/live.rs index 01569e0f..d4e7ed7a 100644 --- a/crates/render/src/live.rs +++ b/crates/render/src/live.rs @@ -10,7 +10,7 @@ use std::collections::HashMap; use std::sync::Arc; use vectorcraft_doc::live::{self, GradientMesh, MeshQuad}; -use vectorcraft_doc::{Appearance, AppearanceItem, FillLayer, Node, NodeKind, StrokeLayer}; +use vectorcraft_doc::{Node, NodeKind}; use vectorcraft_geom::{Affine, BezPath, PathData, Point}; use vello_cpu::RenderContext; use vello_cpu::peniko; @@ -51,12 +51,7 @@ pub(crate) fn outline_text(n: &Node) -> Option { if out.appearance.items.is_empty() && let Some(r) = t.runs.first() { - let mut ap = Appearance::default(); - ap.items.push(AppearanceItem::Fill(FillLayer::new(r.style.fill.clone()))); - if !r.style.stroke.is_none() && r.style.stroke_width > 0.0 { - ap.items.push(AppearanceItem::Stroke(StrokeLayer::new(r.style.stroke.clone(), r.style.stroke_width))); - } - out.appearance = ap; + out.appearance = r.style.appearance(); } out.opacity = n.opacity; out.blend = n.blend; diff --git a/crates/render/src/proof.rs b/crates/render/src/proof.rs index 22ac9629..4faa97cd 100644 --- a/crates/render/src/proof.rs +++ b/crates/render/src/proof.rs @@ -7,10 +7,10 @@ //! plate visible the plate renders as greyscale ink coverage (black = 100%); with several, the //! visible inks are composited through the proof profile, spots multiplied on top. //! Placed raster images are not separated (limitation). -//! * **Overprint Preview:** the document model has no per-object overprint attribute, so -//! overprinting is limited to objects listed in `Document.unknown["overprintBlack"]` (set by -//! Edit → Edit Colors → Overprint Black) whose fill or stroke is 100% K. Such objects are drawn -//! with Multiply, which is what overprinting black ink does to the inks below. +//! * **Overprint Preview** (and Separations Preview, which implies it): fills and strokes that +//! overprint ([`vectorcraft_doc::FillLayer::overprint`], characters' too) are drawn with +//! Multiply, which approximates their inks printing over the inks below: a zero ink lets the +//! inks below show through, where a knockout would replace them. //! //! The app's current view state lives in [`view`] / [`set_view`]; the canvas copies it into //! [`crate::RenderOptions`] with [`active_proof`] and [`overprint_preview_on`]. @@ -21,13 +21,10 @@ use std::sync::{Arc, RwLock}; use vectorcraft_color::cms::{self, Cms, PROCESS_PLATES}; pub use vectorcraft_color::cms::{Intent, ProofSetup, ProofTarget}; use vectorcraft_color::{BlendMode, Color, Paint}; -use vectorcraft_doc::{AppearanceItem, Document, Node, NodeId, NodeKind}; +use vectorcraft_doc::{AppearanceItem, Document, Node, NodeKind}; use crate::RenderOptions; -/// `Document.unknown` key listing the ids of objects marked Overprint Black. -pub const OVERPRINT_KEY: &str = "overprintBlack"; - /// Ink coverage of one paint colour. #[derive(Clone, Debug, PartialEq)] pub struct Inks { @@ -141,25 +138,28 @@ pub fn map_document_colors(doc: &mut Document, f: &mut dyn FnMut(&Color, Option< } } -/// Ids marked Overprint Black. -pub fn overprint_ids(doc: &Document) -> Vec { - doc.unknown.get(OVERPRINT_KEY).and_then(|v| v.as_array()).map(|a| a.iter().filter_map(|v| v.as_u64()).map(NodeId).collect()).unwrap_or_default() +/// Whether overprinting shows: Overprint Preview, or Separations Preview (which implies it). +pub(crate) fn overprints(opts: &RenderOptions) -> bool { + opts.overprint_preview || opts.proof.as_ref().is_some_and(|p| p.separations.is_some()) } -/// 100% black ink with no other process ink. -pub fn is_pure_black(c: &Color) -> bool { - match *c { - Color::Cmyk { c, m, y, k } => k >= 0.995 && c <= 0.005 && m <= 0.005 && y <= 0.005, - Color::Gray { k } => k >= 0.995, - Color::Rgb { .. } => false, +/// Draw the overprinting fills and strokes of `a`'s subtree with Multiply (those with a blend +/// mode of their own keep it), copying only the nodes on the way to them. +fn multiply_overprints(a: &mut Arc) { + if !a.has_overprint() { + return; + } + let n = Arc::make_mut(a); + for it in &mut n.appearance.items { + match it { + AppearanceItem::Fill(l) if l.overprint && l.blend == BlendMode::Normal => l.blend = BlendMode::Multiply, + AppearanceItem::Stroke(l) if l.overprint && l.blend == BlendMode::Normal => l.blend = BlendMode::Multiply, + _ => {} + } + } + for c in n.children_mut().into_iter().flatten() { + multiply_overprints(c); } -} - -fn has_pure_black(n: &Node) -> bool { - n.appearance.items.iter().any(|it| match it { - AppearanceItem::Fill(l) => l.paint.color().is_some_and(|c| is_pure_black(&c)), - AppearanceItem::Stroke(l) => l.paint.color().is_some_and(|c| is_pure_black(&c)), - }) } fn plate_color(doc: &Document, c: &Cms, proof: &ProofSetup, visible: &[String], color: &Color, swatch: Option<&str>) -> Color { @@ -197,18 +197,17 @@ fn plate_color(doc: &Document, c: &Cms, proof: &ProofSetup, visible: &[String], /// The document as it should be drawn for these options (overprints, separations). pub(crate) fn prepare<'a>(doc: &'a Document, opts: &RenderOptions) -> Cow<'a, Document> { let seps = opts.proof.as_ref().and_then(|p| p.separations.as_ref().map(|s| (p, s))); - let overprint = opts.overprint_preview || seps.is_some(); - let ids = if overprint { overprint_ids(doc) } else { vec![] }; - if ids.is_empty() && seps.is_none() { + let overprint = overprints(opts) && (doc.layers.iter().any(|l| l.has_overprint()) || doc.symbols.iter().any(|s| s.art.has_overprint())); + if !overprint && seps.is_none() { return Cow::Borrowed(doc); } let mut d = doc.clone(); - for id in ids { - if let Some(n) = d.node_mut(id) - && n.blend == BlendMode::Normal - && has_pure_black(n) - { - n.blend = BlendMode::Multiply; + if overprint { + for l in &mut d.layers { + multiply_overprints(l); + } + for s in &mut d.symbols { + multiply_overprints(&mut s.art); } } if let Some((proof, visible)) = seps { diff --git a/docs/mcp.md b/docs/mcp.md index 900dc0ae..c429fe9d 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -242,3 +242,17 @@ it (Alt-drag). `appearance.duplicateItem {index, to?}` / `{indices}` and `appear act on several fills/strokes at once, and `appearance.showAllHidden` makes every hidden fill, stroke and effect visible again. In remote mode, `effect.dialog {effect, index, item}` opens an applied effect's dialog prefilled (`ui.dialog.confirm` runs `effect.setParams`); see the control protocol for the "already applied" question. + +## Overprint + +Each fill and stroke (and each character's fill and stroke) can overprint: its inks print over the inks below instead +of knocking them out. `object.setOverprint {fill?, stroke?, item?, ids?}` sets it (groups set their contents, type its +characters too; `item` aims at one appearance item) and `attributes.info {ids?}` reads it back (`null` where the +objects differ). Overprint Preview (`view.overprintPreview`) and Separations Preview show it; rendering approximates +it by multiplying. Older files that listed Overprint Black objects in the document get the flags on load. + +```json +{"name":"run_command","arguments":{"command":"object.setOverprint","params":{"ids":[12],"stroke":true}}} +{"name":"run_command","arguments":{"command":"attributes.info","params":{"ids":[12]}}} +{"name":"run_command","arguments":{"command":"view.overprintPreview","params":{"on":true}}} +``` From cb881b082fac7c56f91ace3f412f0787f4085c63 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 4 Oct 2026 23:54:39 +0700 Subject: [PATCH 2/2] M3.50: Overprint Black dialog with fill/stroke, rich and spot black options Edit > Edit Colors > Overprint Black... opens a parameter dialog (Remove, Percentage, Fill, Stroke, Include Blacks with CMY, Include Spot Blacks). edit.colors.overprintBlack takes fill?, stroke?, includeCmyBlacks? and includeSpotBlacks?; it covers type characters and gradients whose every stop is black, and records no undo step when nothing changes. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/colormgmt.rs | 2 +- crates/engine/src/cmd/overprint.rs | 5 ++- crates/engine/src/tests_overprint.rs | 60 +++++++++++++++++++++++++++ crates/ui-egui/src/dialogs/form.rs | 1 + crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/menus.rs | 6 ++- crates/ui-egui/src/tests_labels.rs | 7 +++- crates/ui-egui/src/tests_overprint.rs | 46 ++++++++++++++++++++ docs/control-protocol.md | 4 ++ 9 files changed, 128 insertions(+), 5 deletions(-) create mode 100644 crates/ui-egui/src/tests_overprint.rs diff --git a/crates/engine/src/cmd/colormgmt.rs b/crates/engine/src/cmd/colormgmt.rs index 9b3378fc..a71a404a 100644 --- a/crates/engine/src/cmd/colormgmt.rs +++ b/crates/engine/src/cmd/colormgmt.rs @@ -111,7 +111,7 @@ pub fn specs() -> Vec { "Overprint Black…", ["Edit", "Edit Colors"], None, - "{remove?: false, percentage?: 100, ids?} make the black fills and strokes (black-only ink ≥ percentage) of the selection (or ids; groups: their contents) overprint, or stop (remove) → {changed: objects}", + "{remove?: false, percentage?: 100, fill?: true, stroke?: true, includeCmyBlacks?: false, includeSpotBlacks?: false, ids?} make the black fills and/or strokes of the selection (or ids; groups: their contents; type: its characters) overprint, or stop (remove). Black: K ≥ percentage with no C, M or Y (any with includeCmyBlacks), not linked to a spot swatch (unless includeSpotBlacks); a gradient is black when every stop is → {changed: objects}", has_doc, super::overprint::overprint_black ), diff --git a/crates/engine/src/cmd/overprint.rs b/crates/engine/src/cmd/overprint.rs index 60fda0cf..eae8acb3 100644 --- a/crates/engine/src/cmd/overprint.rs +++ b/crates/engine/src/cmd/overprint.rs @@ -189,7 +189,10 @@ pub(crate) fn overprint_black(s: &mut Session, p: &Value) -> Result { let op = OverprintBlack { remove: bool_or(p, "remove", false), min_k: (f64_or(p, "percentage", 100.0) / 100.0).clamp(0.0, 1.0) as f32, - ..Default::default() + fill: bool_or(p, "fill", true), + stroke: bool_or(p, "stroke", true), + rich: bool_or(p, "includeCmyBlacks", false), + spot: bool_or(p, "includeSpotBlacks", false), }; let ids = match ids_param(p, "ids") { Some(v) => v, diff --git a/crates/engine/src/tests_overprint.rs b/crates/engine/src/tests_overprint.rs index 81570e6f..78950a83 100644 --- a/crates/engine/src/tests_overprint.rs +++ b/crates/engine/src/tests_overprint.rs @@ -192,3 +192,63 @@ fn old_documents_with_an_overprint_black_list_still_preview() { let back = vectorcraft_format::load(&vectorcraft_format::save(&doc, false)).unwrap(); assert_eq!(back.node(k).unwrap().appearance, n.appearance); } + +fn overprints(s: &Session, id: NodeId) -> (bool, bool) { + let a = &node(s, id).appearance; + (a.fill().unwrap().overprint, a.stroke().unwrap().overprint) +} + +#[test] +fn overprint_black_options() { + let mut s = session(); + let k = rect(&mut s, [0.0, 0.0, 10.0, 10.0], black_ink(), Some(black_ink()), 1.0); + let rich = rect(&mut s, [20.0, 0.0, 10.0, 10.0], ink(60, 40, 40, 100), Some(ink(0, 0, 0, 80)), 1.0); + let rgb = rect(&mut s, [40.0, 0.0, 10.0, 10.0], json!("#000000"), Some(cyan_ink()), 1.0); + let ids = json!([k.0, rich.0, rgb.0]); + + let r = s.execute("edit.colors.overprintBlack", &json!({"ids": ids, "fill": false})).unwrap(); + assert_eq!(r["changed"], 1); + assert_eq!(overprints(&s, k), (false, true), "fill: false marks strokes only"); + s.execute("edit.colors.overprintBlack", &json!({"ids": ids})).unwrap(); + assert_eq!(overprints(&s, k), (true, true)); + assert_eq!(overprints(&s, rich), (false, false), "rich black and 80% K stay out at 100%"); + assert_eq!(overprints(&s, rgb), (false, false), "RGB black is not black ink"); + + let before = undo_len(&s); + s.execute("edit.colors.overprintBlack", &json!({"ids": ids, "includeCmyBlacks": true})).unwrap(); + assert_eq!(overprints(&s, rich), (true, false), "rich black included with CMY"); + assert_eq!(undo_len(&s), before + 1, "one undo step"); + s.execute("edit.colors.overprintBlack", &json!({"ids": ids, "percentage": 75})).unwrap(); + assert_eq!(overprints(&s, rich), (true, true), "80% K at 75%"); + + s.execute("edit.colors.overprintBlack", &json!({"ids": ids, "remove": true, "stroke": false, "includeCmyBlacks": true})).unwrap(); + assert_eq!((overprints(&s, k), overprints(&s, rich)), ((false, true), (false, true)), "remove from fills only"); + let before = undo_len(&s); + let r = s.execute("edit.colors.overprintBlack", &json!({"ids": ids, "remove": true, "stroke": false})).unwrap(); + assert_eq!((r["changed"].clone(), undo_len(&s)), (json!(0), before), "nothing to change, no undo step"); +} + +#[test] +fn overprint_black_covers_type_gradients_spots_and_groups() { + let mut s = session(); + let t = NodeId(s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Hi"})).unwrap()["id"].as_u64().unwrap()); + s.execute("paint.setFill", &json!({"ids": [t.0], "color": black_ink()})).unwrap(); + let g = rect(&mut s, [0.0, 0.0, 10.0, 10.0], black_ink(), None, 0.0); + let stops = json!([{"offset": 0, "color": black_ink()}, {"offset": 1, "color": ink(50, 0, 0, 100)}]); + s.execute("paint.setFill", &json!({"ids": [g.0], "gradient": {"stops": stops}})).unwrap(); + let spot = rect(&mut s, [20.0, 0.0, 10.0, 10.0], black_ink(), None, 0.0); + let name = + s.execute("swatch.new", &json!({"name": "Press Black", "color": black_ink(), "spot": true})).unwrap()["name"].as_str().unwrap().to_string(); + s.execute("paint.setFill", &json!({"ids": [spot.0], "swatch": name})).unwrap(); + s.execute("select.set", &json!({"ids": [t.0, g.0, spot.0]})).unwrap(); + s.execute("object.group", &json!({})).unwrap(); + + s.execute("edit.colors.overprintBlack", &json!({})).unwrap(); + let NodeKind::Text(tx) = &node(&s, t).kind else { panic!() }; + assert!(tx.runs.iter().all(|r| r.style.overprint_fill), "type characters, inside the selected group"); + let fill = |s: &Session, id| node(s, id).appearance.fill().unwrap().overprint; + assert!(!fill(&s, g), "a gradient with a rich-black stop"); + assert!(!fill(&s, spot), "spot blacks need includeSpotBlacks"); + s.execute("edit.colors.overprintBlack", &json!({"includeCmyBlacks": true, "includeSpotBlacks": true})).unwrap(); + assert!(fill(&s, g) && fill(&s, spot)); +} diff --git a/crates/ui-egui/src/dialogs/form.rs b/crates/ui-egui/src/dialogs/form.rs index e6b7aa06..5a2744d7 100644 --- a/crates/ui-egui/src/dialogs/form.rs +++ b/crates/ui-egui/src/dialogs/form.rs @@ -133,6 +133,7 @@ pub(super) fn humanize(k: &str) -> String { "Sy" => "Vertical %".into(), "Radius1" => "Radius 1".into(), "Radius2" => "Radius 2".into(), + "Include Cmy Blacks" => "Include Blacks with CMY:".into(), _ => format!("{s}:"), } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 08a13ff2..4fea0cde 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -33,6 +33,8 @@ pub mod workspaces; #[cfg(test)] mod tests_labels; +#[cfg(test)] +mod tests_overprint; use std::sync::mpsc::{Receiver, Sender}; use std::sync::{Arc, Mutex}; diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index bce706c9..5c682e7b 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -788,7 +788,11 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { c("Convert to Grayscale", "edit.colors.toGrayscale"), c("Convert to RGB", "edit.colors.toRGB"), c("Invert Colors", "edit.colors.invert"), - c("Overprint Black…", "edit.colors.overprintBlack"), + cp( + "Overprint Black…", + "edit.colors.overprintBlack", + json!({"remove": false, "percentage": 100, "fill": true, "stroke": true, "includeCmyBlacks": false, "includeSpotBlacks": false}), + ), cp("Saturate…", "edit.colors.saturate", json!({"intensity": 20})), ], ), diff --git a/crates/ui-egui/src/tests_labels.rs b/crates/ui-egui/src/tests_labels.rs index 7907abce..f2f6ea21 100644 --- a/crates/ui-egui/src/tests_labels.rs +++ b/crates/ui-egui/src/tests_labels.rs @@ -13,8 +13,9 @@ fn app() -> VectorcraftApp { app } -/// Every string painted by `f` in one headless frame, one per line. -fn painted_text(app: &mut VectorcraftApp, mut f: impl FnMut(&mut VectorcraftApp, &mut egui::Ui)) -> String { +/// Every string painted by `f` in a headless frame (app fonts installed), one per line. The second +/// of two frames: windows size themselves invisibly in their first. +pub(crate) fn painted_text(app: &mut VectorcraftApp, mut f: impl FnMut(&mut VectorcraftApp, &mut egui::Ui)) -> String { fn collect(s: &Shape, out: &mut String) { match s { Shape::Text(t) => { @@ -26,6 +27,8 @@ fn painted_text(app: &mut VectorcraftApp, mut f: impl FnMut(&mut VectorcraftApp, } } let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + ctx.run_ui(egui::RawInput::default(), |ui| f(app, ui)).textures_delta.clear(); let mut out = ctx.run_ui(egui::RawInput::default(), |ui| f(app, ui)); out.textures_delta.clear(); let mut text = String::new(); diff --git a/crates/ui-egui/src/tests_overprint.rs b/crates/ui-egui/src/tests_overprint.rs new file mode 100644 index 00000000..3ebc43a5 --- /dev/null +++ b/crates/ui-egui/src/tests_overprint.rs @@ -0,0 +1,46 @@ +//! Edit → Edit Colors → Overprint Black… opens its parameter dialog, which applies the command. + +use serde_json::{Value, json}; +use vectorcraft_doc::NodeId; +use vectorcraft_engine::Session; + +use crate::{VectorcraftApp, dialogs, menus}; + +#[test] +fn overprint_black_menu_opens_the_dialog() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 100, "height": 100})).unwrap(); + let id = NodeId(app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap()["id"].as_u64().unwrap()); + let rich = json!({"c": 60, "m": 40, "y": 40, "k": 100}); + app.run("paint.setFill", json!({"ids": [id.0], "color": rich})).unwrap(); + app.run("paint.setStroke", json!({"ids": [id.0], "color": rich})).unwrap(); + app.run("select.set", json!({"ids": [id.0]})).unwrap(); + + let entry = menus::menu_entries(&app).into_iter().find(|e| e.label == "Overprint Black…").expect("menu item"); + assert_eq!(entry.path, ["Edit", "Edit Colors"]); + assert!(entry.enabled); + let (cmd, params) = menus::click_target(&entry.label, entry.command.as_deref().unwrap(), &entry.params); + menus::invoke(&mut app, &cmd, params); + let d = app.ui.dialog.as_ref().expect("a dialog opens"); + assert_eq!((d.str("__command"), d.str("__label")), ("edit.colors.overprintBlack".to_string(), "Overprint Black".to_string())); + for (k, v) in + [("remove", json!(false)), ("percentage", json!(100)), ("fill", json!(true)), ("stroke", json!(true)), ("includeCmyBlacks", json!(false))] + { + assert_eq!(d.fields.get(k), Some(&v), "{k}"); + } + + // It draws headlessly, with its options labelled. + let text = crate::tests_labels::painted_text(&mut app, |app, ui| dialogs::show(app, ui.ctx())); + for label in ["Overprint Black", "Percentage:", "Include Blacks with CMY:", "Include Spot Blacks:", "Remove:"] { + assert!(text.contains(label), "{label} in {text}"); + } + + // Fill only, with rich blacks: the stroke stays knocked out. + let d = app.ui.dialog.as_mut().unwrap(); + d.fields.insert("includeCmyBlacks".into(), Value::Bool(true)); + d.fields.insert("stroke".into(), Value::Bool(false)); + dialogs::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + let a = &app.session.doc().unwrap().doc.node(id).unwrap().appearance; + assert!(a.fill().unwrap().overprint && !a.stroke().unwrap().overprint); +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 6cfa113f..5369c64a 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -60,3 +60,7 @@ effects) prefilled with its values, and `ui.dialog.confirm` runs `effect.setPara `effect.apply`. Choosing an effect that the list already has returns `{"pending": "effectExists"}` and opens the `effectExists` question: `ui.dialog.confirm` opens the applied effect's dialog, `ui.dialog.set {field: "discard", value: true}` then confirm opens a fresh one that adds another, `ui.dialog.cancel` drops it. + +Overprint Black: the Edit → Edit Colors → Overprint Black… menu item opens a `command` parameter dialog for +`edit.colors.overprintBlack` (fields `remove`, `percentage`, `fill`, `stroke`, `includeCmyBlacks`, +`includeSpotBlacks`); `ui.dialog.set` then `ui.dialog.confirm` runs it on the selection.