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 <noreply@anthropic.com>
This commit is contained in:
parent
6808564486
commit
0c82a3f5c5
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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, 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<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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<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,
|
||||
..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 }))
|
||||
}
|
||||
|
|
@ -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![] };
|
||||
|
|
|
|||
|
|
@ -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))?;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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<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);
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
14
docs/mcp.md
14
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}}}
|
||||
```
|
||||
|
|
|
|||
Loading…
Reference in New Issue