diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index 5eef1a956..f3ff4de7a 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -1006,7 +1006,12 @@ impl Exporter<'_> { for (si, gp) in designcraft_compose::path_glyphs(&cs, ft, &bp, pt) { let st = &cs.styles[si as usize]; // A gradient spans the path's box. - let fill = designcraft_doc::Fill { swatch: st.fill.clone(), tint: st.fill_tint, ..designcraft_doc::Fill::none() }; + let fill = designcraft_doc::Fill { + swatch: st.fill.clone(), + tint: st.fill_tint, + overprint: st.overprint_fill, + ..designcraft_doc::Fill::none() + }; if let (Some(paint), Some(p)) = (self.fill_paint(&fill, bp.bounding_box()), to_path(&gp)) { let op = self.overprints(&st.fill, st.fill_tint, st.overprint_fill); let op = self.push_overprint(s, op, false); @@ -1103,7 +1108,8 @@ impl Exporter<'_> { let (swatch, tint) = (fill.swatch.as_str(), fill.tint); if let Some(g) = designcraft_color::swatch::resolve_gradient(&self.doc.swatches, swatch) { let kind = g.kind; - let stops = match self.ink_gradient_stops(g, swatch) { + let overprint = self.overprints(swatch, tint, fill.overprint); + let stops = match self.ink_gradient_stops(g, swatch, overprint) { Some(stops) => stops, None => self.device_gradient_stops(g, swatch), }; diff --git a/crates/pdf/src/gradient_inks.rs b/crates/pdf/src/gradient_inks.rs index 1a258a1dc..e889ee1d2 100644 --- a/crates/pdf/src/gradient_inks.rs +++ b/crates/pdf/src/gradient_inks.rs @@ -1,7 +1,10 @@ //! Gradients written in their inks. A gradient whose stops name a spot swatch shades in that //! spot: one spot with paper or tints of itself is a Separation shading, a spot with process //! inks or a second spot a DeviceN shading (process colorants in CMYK order, then the spots). -//! In print, a process gradient with a `[Paper]` stop shades in DeviceCMYK. +//! In print, a process gradient with a `[Paper]` stop shades in DeviceCMYK. An overprinting +//! gradient of fewer than four process inks is a DeviceN shading of just those inks: nonzero +//! overprint mode doesn't apply to shadings (ISO 32000-2 §8.6.7), so a DeviceCMYK shading's +//! zero components would knock out the plates beneath. use craft_pdf::write::backend as krilla; use designcraft_color::swatch::{ColorType, SwatchValue, apply_tint}; @@ -73,16 +76,18 @@ impl Exporter<'_> { } /// The gradient's stops in its inks, or `None` to write it in device colors (no spot, or - /// inks that can't share one shading space, which is warned about). - pub(crate) fn ink_gradient_stops(&mut self, g: &Gradient, swatch: &str) -> Option> { + /// inks that can't share one shading space, which is warned about). `overprint`: the paint + /// overprints. + pub(crate) fn ink_gradient_stops(&mut self, g: &Gradient, swatch: &str, overprint: bool) -> Option> { + let overprint = overprint && !self.rgb_only; // Plain-color stops (all older documents) keep their device colors. - if g.stops.iter().all(|s| s.swatch.is_none()) { + if !overprint && g.stops.iter().all(|s| s.swatch.is_none()) { return None; } let inks: Vec> = g.stops.iter().map(|s| self.stop_ink(s)).collect(); let has_spot = inks.iter().any(|i| matches!(i, Some(Ink::Spot { .. }))); let on_paper = g.stops.iter().any(|s| s.swatch.as_deref() == Some(designcraft_color::swatch::PAPER)); - if !has_spot && !(on_paper && self.for_print() && !self.rgb_only) { + if !has_spot && !overprint && !(on_paper && self.for_print() && !self.rgb_only) { return None; } let Some(inks) = inks.into_iter().collect::>>() else { @@ -107,6 +112,14 @@ impl Exporter<'_> { } let process: Vec = (0..4).filter(|j| process.get(*j).copied().unwrap_or(false)).collect(); let space = match (spots.as_slice(), process.is_empty()) { + // All four inks are painted either way. + ([], false) if overprint && process.len() < 4 => { + let colorants: Vec = process.iter().filter_map(|j| Process::ALL.get(*j).copied().map(Colorant::Process)).collect(); + match DeviceNSpace::new(colorants) { + Some(s) => Some(Space::DeviceN(s)), + None => return self.unshaded(swatch, "its inks don't fit one DeviceN space", false), + } + } ([], _) => None, ([(name, alternate)], true) => { Some(Space::Separation(SeparationSpace::new(SeparationColorant::Custom(name.clone()), regular(alternate, self.rgb_only)))) @@ -167,7 +180,8 @@ impl Exporter<'_> { let color: krilla::color::Color = match &space { Some(Space::Separation(sp)) => separation::Color::new(q(v.first().copied().unwrap_or(0.0)), sp.clone()).into(), Some(Space::DeviceN(sp)) => device_n::Color::new(v.iter().map(|t| q(*t)).collect(), sp.clone())?.into(), - // Process inks only (a gradient to [Paper] in print). + // Process inks only (a gradient to [Paper] in print, or an overprinting one of + // all four inks). None => { let mut cmyk = [0.0f32; 4]; for (t, j) in v.iter().zip(&process) { diff --git a/crates/pdf/src/text.rs b/crates/pdf/src/text.rs index baecd78cc..c738ee704 100644 --- a/crates/pdf/src/text.rs +++ b/crates/pdf/src/text.rs @@ -32,10 +32,11 @@ use crate::export::{Exporter, solid_fill, tf, to_path}; impl Exporter<'_> { /// A text run's paint: solid, or a gradient spanning the frame's box (`area`, text space), - /// for glyphs drawn under `origin` (text space ← run space). - fn text_paint(&mut self, swatch: &str, tint: f32, origin: Affine, area: Rect) -> Option { + /// for glyphs drawn under `origin` (text space ← run space). `overprint`: the run's + /// overprint attribute for this paint. + fn text_paint(&mut self, swatch: &str, tint: f32, overprint: bool, origin: Affine, area: Rect) -> Option { if designcraft_color::swatch::resolve_gradient(&self.doc.swatches, swatch).is_some() && origin.determinant().abs() > 1e-12 { - let fill = designcraft_doc::Fill { swatch: swatch.into(), tint, ..designcraft_doc::Fill::none() }; + let fill = designcraft_doc::Fill { swatch: swatch.into(), tint, overprint, ..designcraft_doc::Fill::none() }; return self.fill_paint_in(&fill, self.text_area.unwrap_or(area), origin.inverse()); } self.swatch_color(swatch, tint).map(Into::into) @@ -474,9 +475,9 @@ impl Exporter<'_> { // Outside a frame (no text area), a gradient spans the run. let end = glyphs.last().map_or(g0.x, |g| g.x + g.adv); let run_area = Rect::new(g0.x, baseline - size, end.max(g0.x + 1.0), baseline + size * 0.25); - let fill = self.text_paint(&st.fill, st.fill_tint, origin, run_area); + let fill = self.text_paint(&st.fill, st.fill_tint, st.overprint_fill, origin, run_area); let stroke = if st.stroke != designcraft_color::swatch::NONE && st.stroke_weight > 0.0 { - self.text_paint(&st.stroke, st.stroke_tint, origin, run_area) + self.text_paint(&st.stroke, st.stroke_tint, st.overprint_stroke, origin, run_area) } else { None }; diff --git a/crates/pdf/tests/it/spot_gradients.rs b/crates/pdf/tests/it/spot_gradients.rs index abbc39953..eac962bc9 100644 --- a/crates/pdf/tests/it/spot_gradients.rs +++ b/crates/pdf/tests/it/spot_gradients.rs @@ -19,6 +19,11 @@ fn spot(d: &mut Document, name: &str, color: Color) { /// A document with spot inks Gold and Silver, a 40 % tint of Gold, and one box filled with a gradient on `stops` /// (swatch name or plain color, location). fn document(stops: &[(Result<&str, Color>, f32)], midpoint: f32) -> Document { + document_with(stops, midpoint, false) +} + +/// [`document`], the box's fill overprinting when `overprint`. +fn document_with(stops: &[(Result<&str, Color>, f32)], midpoint: f32, overprint: bool) -> Document { let mut d = Document::new(&NewDocument { width: 120.0, height: 120.0, facing_pages: false, ..Default::default() }); spot(&mut d, "Gold", Color::cmyk(0.0, 0.3, 0.9, 0.1)); spot(&mut d, "Silver", Color::cmyk(0.2, 0.15, 0.1, 0.3)); @@ -35,7 +40,7 @@ fn document(stops: &[(Result<&str, Color>, f32)], midpoint: f32) -> Document { ..Swatch::color("Fade", Color::BLACK) }); let mut item = Item::new(ItemId(d.alloc()), d.default_layer(), Shape::Rectangle, shapes::rectangle(Rect::new(10.0, 10.0, 110.0, 110.0))); - item.fill = Fill::swatch("Fade"); + item.fill = Fill { overprint, ..Fill::swatch("Fade") }; d.insert_item(SpreadRef::Doc(0), item, None).unwrap(); d } @@ -169,6 +174,28 @@ fn a_process_gradient_to_paper_prints_in_cmyk() { assert!(close(&shadings[0].start, &[1.0, 0.0, 0.0, 0.0]) && close(&shadings[0].end, &[0.0, 0.0, 0.0, 0.0])); } +/// Nonzero overprint mode doesn't apply to shadings, so an overprinting process gradient names +/// only the inks it uses and leaves the other plates alone. +#[test] +fn an_overprinting_process_gradient_is_a_device_n_shading_of_its_inks() { + for standard in [Standard::None, Standard::PdfX4] { + let (shadings, warnings) = export(&document_with(&[(Ok(CYAN), 0.0), (Ok("[Paper]"), 1.0)], 0.5, true), standard); + no_conversion(&warnings); + let s = &shadings[0]; + assert_eq!((s.family.as_str(), s.colorants.as_slice()), ("DeviceN", &["Cyan".to_string()][..]), "{standard:?}"); + assert!(close(&s.start, &[1.0]) && close(&s.end, &[0.0]), "{:?} → {:?}", s.start, s.end); + } + // Plain-color stops too. + let c_to_m = [(Err(Color::cmyk(1.0, 0.0, 0.0, 0.0)), 0.0), (Err(Color::cmyk(0.0, 1.0, 0.0, 0.0)), 1.0)]; + let (shadings, _) = export(&document_with(&c_to_m, 0.5, true), Standard::PdfX4); + let s = &shadings[0]; + assert_eq!((s.family.as_str(), s.colorants.as_slice()), ("DeviceN", &["Cyan".to_string(), "Magenta".to_string()][..])); + assert!(close(&s.start, &[1.0, 0.0]) && close(&s.end, &[0.0, 1.0]), "{:?} → {:?}", s.start, s.end); + // Without overprint it stays in DeviceCMYK. + let (shadings, _) = export(&document(&c_to_m, 0.5), Standard::PdfX4); + assert_eq!(shadings[0].family, "DeviceCMYK"); +} + /// PDF/A forbids DeviceN's PostScript tint transform: the gradient goes to process color, with /// a warning, and the export still succeeds. #[test]