mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-25 10:17:02 -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>
146 lines
6.5 KiB
Rust
146 lines
6.5 KiB
Rust
//! File → Print › Advanced and Color Management: overprints preserved, discarded or simulated in
|
||
//! composite output, and colours converted to the printer profile with the rendering intent.
|
||
|
||
use std::sync::Arc;
|
||
|
||
use hayro_syntax::Pdf;
|
||
use serde_json::{Value, json};
|
||
use vectorcraft_color::cms::{self, Cms, ColorSettings, DEVICE_CMYK, GENERIC_CMYK, Intent};
|
||
use vectorcraft_color::{Color, Paint};
|
||
use vectorcraft_doc::{Appearance, Document, Node};
|
||
use vectorcraft_geom::{Rect, shapes};
|
||
|
||
use crate::*;
|
||
|
||
/// A 200 × 100 document with a rectangle filled with each `(colour, overprints)`, overlapping
|
||
/// one another left to right, in paint order.
|
||
fn doc(fills: &[(Color, bool)]) -> Document {
|
||
let mut d = Document::new(200.0, 100.0);
|
||
let l = d.layers[0].id;
|
||
for (i, (c, over)) in fills.iter().enumerate() {
|
||
let x = 10.0 + 30.0 * i as f64;
|
||
let mut a = Appearance::basic(Paint::solid(*c), Paint::None, 0.0);
|
||
for it in &mut a.items {
|
||
*it.overprint_mut() = *over;
|
||
}
|
||
let n = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(x, 10.0, x + 50.0, 60.0)), a);
|
||
d.insert(Some(l), i, n).unwrap();
|
||
}
|
||
d
|
||
}
|
||
|
||
/// Cyan under a yellow that overprints.
|
||
fn overprinted() -> Document {
|
||
doc(&[(Color::cmyk(1.0, 0.0, 0.0, 0.0), false), (Color::cmyk(0.0, 0.0, 1.0, 0.0), true)])
|
||
}
|
||
|
||
fn settings(v: Value) -> PrintSettings {
|
||
serde_json::from_value(v).unwrap()
|
||
}
|
||
|
||
/// The job's bytes, content streams left readable.
|
||
fn job(d: &Document, v: Value) -> PrintReport {
|
||
let opts = PrintOptions { settings: settings(v), created: Some(1_791_200_000), uncompressed: true, ..Default::default() };
|
||
print(d, &opts).unwrap()
|
||
}
|
||
|
||
/// The fill colours page `page` sets: (operator, operands).
|
||
fn colours(pdf: &[u8], page: usize) -> Vec<(String, Vec<f32>)> {
|
||
let file = Pdf::new(Arc::new(pdf.to_vec())).unwrap();
|
||
let pages = file.pages();
|
||
let content = String::from_utf8_lossy(pages.get(page).unwrap().page_stream().unwrap()).into_owned();
|
||
let (mut nums, mut out) = (vec![], vec![]);
|
||
for t in content.split_whitespace() {
|
||
if let Ok(v) = t.parse::<f32>() {
|
||
nums.push(v);
|
||
continue;
|
||
}
|
||
if ["rg", "k", "g", "scn"].contains(&t) {
|
||
out.push((t.to_string(), nums.clone()));
|
||
}
|
||
nums.clear();
|
||
}
|
||
out
|
||
}
|
||
|
||
/// `v` as the writer stores it (8 bits a component).
|
||
fn q8(v: &[f32]) -> Vec<f32> {
|
||
v.iter().map(|v| (v.clamp(0.0, 1.0) * 255.0).round() / 255.0).collect()
|
||
}
|
||
|
||
fn close(a: &[f32], b: &[f32]) -> bool {
|
||
a.len() == b.len() && a.iter().zip(b).all(|(a, b)| (a - b).abs() < 1e-3)
|
||
}
|
||
|
||
/// Does any fill or stroke of `d` overprint?
|
||
fn overprints(d: &Document) -> bool {
|
||
d.layers.iter().any(|l| l.has_overprint())
|
||
}
|
||
|
||
#[test]
|
||
fn settings_saved_before_the_advanced_options_load_with_their_defaults() {
|
||
let s = settings(json!({"copies": 2, "color": {"intent": "perceptual"}}));
|
||
assert_eq!((s.copies, s.color.intent, s.color.preserve_numbers), (2, Intent::Perceptual, true));
|
||
assert_eq!(s.advanced, PrintAdvanced::default());
|
||
assert_eq!((s.advanced.print_as_bitmap, s.advanced.overprints, s.advanced.flattener_preset.as_str()), (false, PrintOverprints::Preserve, ""));
|
||
assert_eq!(s.color.profile, "");
|
||
let v = serde_json::to_value(&s).unwrap();
|
||
assert_eq!(v["advanced"], json!({"printAsBitmap": false, "overprints": "preserve", "flattenerPreset": ""}));
|
||
// A printer profile must be one there is.
|
||
assert!(settings(json!({"color": {"profile": GENERIC_CMYK}})).check().is_ok());
|
||
assert!(matches!(settings(json!({"color": {"profile": "No Such Profile"}})).check(), Err(PdfError::BadSetting(_))));
|
||
}
|
||
|
||
#[test]
|
||
fn discarded_and_simulated_overprints_write_no_overprint() {
|
||
let d = overprinted();
|
||
let preserve = job(&d, json!({}));
|
||
assert!(preserve.warnings.is_empty(), "{:?}", preserve.warnings);
|
||
assert!(String::from_utf8_lossy(&preserve.bytes).contains("/OP true/op true/OPM 1"), "preserved overprints overprint in the file");
|
||
assert!(overprints(&printed_document(&d, &settings(json!({})))), "preserved");
|
||
for (how, multiplies) in [("discard", false), ("simulate", true)] {
|
||
let set = json!({"advanced": {"overprints": how}});
|
||
assert!(!overprints(&printed_document(&d, &settings(set.clone()))), "{how}: nothing overprints");
|
||
let r = job(&d, set);
|
||
let text = String::from_utf8_lossy(&r.bytes);
|
||
assert!(!text.contains("/OP"), "{how}: no overprint in the file");
|
||
assert_eq!(text.contains("/Multiply"), multiplies, "{how}");
|
||
assert!(!r.warnings.iter().any(|w| w.contains("knockouts")), "{how}: {:?}", r.warnings);
|
||
}
|
||
// Separations honour overprints whatever the option says.
|
||
let seps = settings(json!({"output": {"mode": "separations"}, "advanced": {"overprints": "discard"}}));
|
||
assert!(overprints(&printed_document(&d, &seps)));
|
||
}
|
||
|
||
#[test]
|
||
fn a_cmyk_printer_profile_writes_device_cmyk_with_the_intent() {
|
||
let d = doc(&[(Color::rgb(1.0, 0.0, 0.0), false)]);
|
||
assert_eq!(colours(&job(&d, json!({})).bytes, 0), [("rg".to_string(), vec![1.0, 0.0, 0.0])], "as it is without a profile");
|
||
let to_cmyk = Cms::new(&ColorSettings { cmyk: GENERIC_CMYK.into(), ..cms::active_settings() }).unwrap();
|
||
for intent in [Intent::Perceptual, Intent::Saturation] {
|
||
let c = colours(&job(&d, json!({"color": {"profile": GENERIC_CMYK, "intent": intent}})).bytes, 0);
|
||
let ink = q8(&to_cmyk.srgb_to_cmyk([1.0, 0.0, 0.0], intent));
|
||
assert!(c.len() == 1 && c[0].0 == "k" && close(&c[0].1, &ink), "{intent:?}: {c:?} vs {ink:?}");
|
||
}
|
||
// CMYK colours keep their numbers with Preserve CMYK Numbers.
|
||
let d = doc(&[(Color::cmyk(0.1, 0.2, 0.3, 0.4), false)]);
|
||
let c = colours(&job(&d, json!({"color": {"profile": DEVICE_CMYK, "preserveNumbers": true}})).bytes, 0);
|
||
assert!(c[0].0 == "k" && close(&c[0].1, &q8(&[0.1, 0.2, 0.3, 0.4])), "{c:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn separations_separate_with_a_cmyk_printer_profile() {
|
||
// A mid grey: naive device CMYK puts it all on the black plate; a press profile doesn't.
|
||
let d = doc(&[(Color::rgb(0.5, 0.5, 0.5), false)]);
|
||
let cyan = |profile: &str| {
|
||
let r = job(&d, json!({"output": {"mode": "separations"}, "color": {"profile": profile}}));
|
||
colours(&r.bytes, 0)
|
||
};
|
||
let device = cyan(DEVICE_CMYK);
|
||
let press = cyan(GENERIC_CMYK);
|
||
assert_ne!(device, press, "the profile separates");
|
||
// An RGB printer profile doesn't separate.
|
||
let r = job(&d, json!({"output": {"mode": "separations"}, "color": {"profile": cms::SRGB}}));
|
||
assert!(r.warnings.iter().any(|w| w.contains("RGB printer profile")), "{:?}", r.warnings);
|
||
}
|