Files
vectorcraft/crates/doc/src/overprint.rs
bflatasticandClaude Opus 5.5 a09cbe58c8 M4.83: overprinting fills and strokes overprint in PDF
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>
2026-10-05 17:39:28 +07:00

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