Merge pull request #20 from storytold/pr/m3-overprint

M3: overprint per fill and stroke, Overprint Black options (M3.49, M3.50)
This commit is contained in:
BFlat 2026-10-05 00:07:34 +07:00 committed by GitHub
commit 6c25e54914
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 846 additions and 113 deletions

View File

@ -184,11 +184,15 @@ pub struct FillLayer {
pub visible: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub effects: Vec<Effect>,
/// 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

View File

@ -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;

203
crates/doc/src/overprint.rs Normal file
View File

@ -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<String> {
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<NodeId> = 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);
}
}

View File

@ -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<String>,
/// 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).

View File

@ -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<CommandSpec> {
"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, 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,
overprint_black
super::overprint::overprint_black
),
cmd!(
"swatch.setSpot",
@ -433,55 +433,6 @@ fn separations(s: &mut Session, p: &Value) -> Result<Value> {
Ok(plates_json(s))
}
fn overprint_black(s: &mut Session, p: &Value) -> Result<Value> {
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<u64> = 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<u64> = 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<Value> {
const C: &str = "swatch.setSpot";
let name = str_param(p, "name").ok_or_else(|| bad(C, "missing `name`"))?.to_string();

View File

@ -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;

View File

@ -0,0 +1,206 @@
//! 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<CommandSpec> {
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<NodeId>,
/// Overprint Fill; `None` where the targets differ or have no fill.
pub overprint_fill: Option<bool>,
/// Overprint Stroke; `None` where the targets differ or have no stroke.
pub overprint_stroke: Option<bool>,
}
impl AttributesInfo {
fn to_json(&self) -> Value {
json!({
"ids": self.ids.iter().map(|id| id.0).collect::<Vec<_>>(),
"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<usize>) -> Vec<bool> {
let mut v: Vec<bool> = 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<usize>) -> 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<Vec<(bool, bool)>> {
[(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<Item = bool>) -> Result<Vec<(bool, ItemTarget, Vec<NodeId>)>> {
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<usize>) -> Result<usize> {
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<Value> {
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<AttributesInfo> {
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<Value> {
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,
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,
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 }))
}

View File

@ -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![] };

View File

@ -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<CommandSpec> {
@ -103,7 +103,7 @@ fn create_outlines(s: &mut Session, _: &Value) -> Result<Value> {
}
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))?;

View File

@ -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;

View File

@ -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]

View File

@ -0,0 +1,254 @@
//! 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<Value>, 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<usize> = (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<bool> = 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);
}
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));
}

View File

@ -99,6 +99,8 @@ pub fn load(bytes: &[u8]) -> Result<Document, FormatError> {
}
// 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)
}

View File

@ -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);

View File

@ -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<Node> {
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;

View File

@ -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<NodeId> {
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<Node>) {
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 {

View File

@ -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}:"),
}
}

View File

@ -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};

View File

@ -788,7 +788,11 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<Item>)> {
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})),
],
),

View File

@ -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();

View File

@ -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);
}

View File

@ -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.

View File

@ -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}}}
```