mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-26 18:56:55 -06:00
Advanced > Overprint: Preserve (the default; document.exportPdf advanced.overprint) writes each overprinting fill and stroke (an object's own, a character's, a type object's) with a graphics state of /OP true /op true /OPM 1 (/OPM 0 in PDF/A); Discard writes none. White ones knock out when Document Setup's Discard White Overprint is on, as in print. Mask art never overprints. File > Print's composite PDF honours Print > Advanced > Overprints: Preserve the same way (separations keep multiplying plates). Each overprinting fill or stroke is drawn as a form marked to overprint (crates/pdf/src/forms.rs); after writing, its content starts by setting the overprinting graphics state, and the PDF/X self-check runs on the result. The engine's "overprint is not written to PDF yet" warning and print's "overprints print as knockouts in composite output" warning are gone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
267 lines
12 KiB
Rust
267 lines
12 KiB
Rust
//! 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 std::sync::Arc;
|
|
|
|
use vectorcraft_color::{BlendMode, 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 and Lab 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 { .. } | Color::Lab { .. } => 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()))
|
|
}
|
|
}
|
|
|
|
/// 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: on screen and on a
|
|
/// printed plate this approximates their inks printing over the inks below. With `discard_white`
|
|
/// (Document Setup → Discard White Overprint) white fills and strokes knock out instead.
|
|
pub fn multiply_overprints(a: &mut Arc<Node>, discard_white: bool) {
|
|
if !a.has_overprint() {
|
|
return;
|
|
}
|
|
let n = Arc::make_mut(a);
|
|
let multiplies =
|
|
|overprint: bool, blend: BlendMode, paint: &Paint| overprint && blend == BlendMode::Normal && !(discard_white && is_white(paint));
|
|
for it in &mut n.appearance.items {
|
|
match it {
|
|
crate::AppearanceItem::Fill(l) if multiplies(l.overprint, l.blend, &l.paint) => l.blend = BlendMode::Multiply,
|
|
crate::AppearanceItem::Stroke(l) if multiplies(l.overprint, l.blend, &l.paint) => l.blend = BlendMode::Multiply,
|
|
_ => {}
|
|
}
|
|
}
|
|
for c in n.children_mut().into_iter().flatten() {
|
|
multiply_overprints(c, discard_white);
|
|
}
|
|
}
|
|
|
|
/// A solid paint of paper white (no ink in any colour model).
|
|
pub fn is_white(p: &Paint) -> bool {
|
|
let Paint::Solid { color, .. } = p else { return false };
|
|
match *color {
|
|
Color::Rgb { r, g, b } => r.min(g).min(b) >= 1.0,
|
|
Color::Cmyk { c, m, y, k } => c.max(m).max(y).max(k) <= 0.0,
|
|
Color::Gray { k } => k <= 0.0,
|
|
Color::Lab { l, a, b } => l >= 100.0 && a == 0.0 && b == 0.0,
|
|
}
|
|
}
|
|
|
|
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, &[]);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Whether any fill or stroke of `n`'s subtree overprints, characters' too.
|
|
fn overprints_anywhere(n: &Node) -> bool {
|
|
n.appearance.items.iter().any(|i| i.overprint())
|
|
|| matches!(&n.kind, NodeKind::Text(t) if t.runs.iter().any(|r| r.style.overprint_fill || r.style.overprint_stroke))
|
|
|| n.children().is_some_and(|c| c.iter().any(|c| overprints_anywhere(c)))
|
|
}
|
|
|
|
/// Stop every fill and stroke of `a`'s subtree overprinting (characters' too), copying only the
|
|
/// nodes on the way to them.
|
|
pub fn clear_overprints(a: &mut Arc<Node>) {
|
|
if !overprints_anywhere(a) {
|
|
return;
|
|
}
|
|
let n = Arc::make_mut(a);
|
|
for it in &mut n.appearance.items {
|
|
*it.overprint_mut() = false;
|
|
}
|
|
if let NodeKind::Text(t) = &mut n.kind {
|
|
for r in &mut t.runs {
|
|
(r.style.overprint_fill, r.style.overprint_stroke) = (false, false);
|
|
}
|
|
}
|
|
for c in n.children_mut().into_iter().flatten() {
|
|
clear_overprints(c);
|
|
}
|
|
}
|
|
|
|
#[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()), tint: 1.0 };
|
|
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 { panic!("type") };
|
|
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 { panic!("type") };
|
|
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);
|
|
}
|
|
}
|