diff --git a/Cargo.lock b/Cargo.lock index 8e85bad40..72b06a51a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2863,7 +2863,7 @@ dependencies = [ [[package]] name = "krilla" version = "0.8.2" -source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +source = "git+https://github.com/storytold/craft-libs?rev=fb08be5fa19eee8d37e624397fcdc96df6035e92#fb08be5fa19eee8d37e624397fcdc96df6035e92" dependencies = [ "base64 0.22.1", "bumpalo", diff --git a/Cargo.toml b/Cargo.toml index 1876edbf7..fe5459829 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -152,7 +152,9 @@ hayro-interpret = { git = "https://github.com/storytold/craft-libs", rev = "92d3 hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } hayro-cmap = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } hayro-postscript = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } -krilla = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } +# The PDF writer takes craft-libs#417 (CMYK, gray and spot colors keep their exact inks); the other +# craft-libs crates stay at 92d392cf. craft-pdf's sources are the same at both revisions. +krilla = { git = "https://github.com/storytold/craft-libs", rev = "fb08be5fa19eee8d37e624397fcdc96df6035e92" } ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } diff --git a/crates/color/src/gradient.rs b/crates/color/src/gradient.rs index e44b0be9e..402cf3ae2 100644 --- a/crates/color/src/gradient.rs +++ b/crates/color/src/gradient.rs @@ -79,7 +79,7 @@ const PERCEPTUAL_TOLERANCE: f32 = 0.4 / 255.0; /// halved to keep within [`PERCEPTUAL_TOLERANCE`]. const PERCEPTUAL_DEPTH: u32 = 10; /// How many times [`Gradient::ink_tints`] halves the range of tints it searches: to within -/// 1/65536 of a tint, finer than the 1/255 steps PDF writes. +/// 1/65536 of a tint. const TINT_STEPS: u32 = 16; /// The least OKLab lightness by which an ink at 100% must differ from paper white for /// [`Gradient::ink_tints`] to tell its tints apart by lightness. @@ -277,6 +277,11 @@ impl Gradient { /// [`Self::expanded_stops`], each stop that stands for a midpoint with that midpoint (`None` /// on the gradient's own stops and a Perceptual gradient's other in-between stops). pub fn expanded(&self) -> impl Iterator)> + '_ { + self.expanded_with_stops().map(|(t, c, o, mid, _)| (t, c, o, mid)) + } + /// [`Self::expanded`], each of the gradient's own stops with its index in [`Self::stops`] + /// (`None` on the stops in between). + fn expanded_with_stops(&self) -> impl Iterator, Option)> + '_ { // Direct interpolation matches `sample` only for strictly increasing offsets. let increasing = self.stops.windows(2).all(|w| w[0].offset < w[1].offset); self.stops.iter().enumerate().flat_map(move |(i, s)| { @@ -289,7 +294,7 @@ impl Gradient { // stops again for each midpoint made many stops cost their square. let mix = |a: &Color, b: &Color, v| self.method.mix(a, b, v); let (c, o) = if increasing { between(s, n, t, &mix) } else { self.sample_with(t, mix) }; - (t, c, o, Some(s.midpoint)) + (t, c, o, Some(s.midpoint), None) }); // Perceptual: stops along the mix (`v`) where a straight line would stray from it, the // midpoint (v = ½) among them, placed through the midpoint as [`Self::sample`] maps them. @@ -298,26 +303,27 @@ impl Gradient { let mid = v == 0.5; let t = curve_offset(s, n, v); let o = s.opacity + (n.opacity - s.opacity) * v; - (t, self.method.mix(&s.color, &n.color, v), o, (mid && off_centre).then_some(s.midpoint)) + (t, self.method.mix(&s.color, &n.color, v), o, (mid && off_centre).then_some(s.midpoint), None) }) }); - std::iter::once((s.offset, s.color, s.opacity, None)).chain(classic).chain(perceptual.into_iter().flatten()) + std::iter::once((s.offset, s.color, s.opacity, None, Some(i))).chain(classic).chain(perceptual.into_iter().flatten()) }) } /// A gradient of tints of one ink `ink` (its color at 100%; paper white is 0% of it) as /// (offset, tint, opacity) at each of [`Self::expanded_stops`]: the tint (0..1) whose OKLab - /// lightness is the lightness of the color there, found to [`TINT_STEPS`] halvings. Mixed - /// linearly between them, the tints follow a Perceptual gradient's curve. `None` when the - /// ink's lightness is within [`MIN_INK_LIGHTNESS_SPAN`] of paper white's (a white or nearly - /// white ink), so lightness does not tell its tints apart, and when the curve has more than - /// [`MAX_INK_TINTS`] stops ([`Self::fits_ink_tints`]). - pub fn ink_tints(&self, ink: &Color) -> Option> { + /// lightness is the lightness of the color there, found to [`TINT_STEPS`] halvings, except at + /// the gradient's own stops, which take their tints from `stop_tints` (one per stop, in + /// order) where it has one. Mixed linearly between them, the tints follow a Perceptual + /// gradient's curve. `None` when the ink's lightness is within [`MIN_INK_LIGHTNESS_SPAN`] of + /// paper white's (a white or nearly white ink), so lightness does not tell its tints apart, + /// and when the curve has more than [`MAX_INK_TINTS`] stops ([`Self::fits_ink_tints`]). + pub fn ink_tints(&self, ink: &Color, stop_tints: &[f32]) -> Option> { use crate::libraries::srgb_to_oklab; let lightness = |c: &Color| srgb_to_oklab(c.to_rgb())[0]; if lightness(&ink.tinted(0.0)) - lightness(ink) < MIN_INK_LIGHTNESS_SPAN { return None; } - let curve: Vec<_> = self.expanded().take(MAX_INK_TINTS + 1).collect(); + let curve: Vec<_> = self.expanded_with_stops().take(MAX_INK_TINTS + 1).collect(); if curve.len() > MAX_INK_TINTS { return None; } @@ -334,7 +340,7 @@ impl Gradient { } (lo + hi) / 2.0 }; - Some(curve.into_iter().map(|(t, c, o, _)| (t, tint(&c), o)).collect()) + Some(curve.into_iter().map(|(t, c, o, _, i)| (t, i.and_then(|i| stop_tints.get(i).copied()).unwrap_or_else(|| tint(&c)), o)).collect()) } /// Does the gradient's curve ([`Self::expanded`]) have at most [`MAX_INK_TINTS`] stops, so /// [`Self::ink_tints`] finds tints for it? diff --git a/crates/color/src/gradient/tests.rs b/crates/color/src/gradient/tests.rs index ab195b347..224803075 100644 --- a/crates/color/src/gradient/tests.rs +++ b/crates/color/src/gradient/tests.rs @@ -466,10 +466,14 @@ fn ink_tints_follow_the_perceptual_curve() { let mut g = Gradient::new(GradientKind::Linear, vec![GradientStop::new(0.0, ink.tinted(0.8)), GradientStop::new(1.0, Color::WHITE)]); g.method = GradientMethod::Perceptual; assert!(g.fits_ink_tints()); - let tints = g.ink_tints(&ink).expect("a magenta ink's tints differ in lightness"); + let tints = g.ink_tints(&ink, &[]).expect("a magenta ink's tints differ in lightness"); assert!(tints.len() > 2, "the curve's stops: {tints:?}"); let (first, last) = (tints[0], tints[tints.len() - 1]); assert!((first.0, last.0) == (0.0, 1.0) && (first.1 - 0.8).abs() < 1e-4 && last.1 < 1e-4, "{tints:?}"); + // The stops' own tints, when given, are kept exactly; the tints in between are the same. + let own = g.ink_tints(&ink, &[0.8, 0.0]).expect("tints"); + assert!(own.len() == tints.len() && own[0].1 == 0.8 && own[own.len() - 1].1 == 0.0, "{own:?}"); + assert!(own[1..own.len() - 1] == tints[1..tints.len() - 1], "{own:?}"); assert!(tints.windows(2).all(|w| w[0].0 <= w[1].0 && w[0].1 >= w[1].1), "in order, lighter along the way: {tints:?}"); let lightness = |c: &Color| srgb_to_oklab(c.to_rgb())[0]; for (t, tint, _) in &tints { @@ -479,11 +483,11 @@ fn ink_tints_follow_the_perceptual_curve() { let mid = tints.iter().find(|s| (s.0 - 0.5).abs() < 1e-6).expect("the midpoint"); assert!((mid.1 - 0.4).abs() > 0.01, "not halfway in tint: {mid:?}"); // A white ink's tints all have paper white's lightness: no tints by lightness. - assert_eq!(g.ink_tints(&Color::WHITE), None); + assert_eq!(g.ink_tints(&Color::WHITE, &[]), None); // More stops on the curve than MAX_INK_TINTS: no tints. let n = MAX_INK_TINTS + 1; g.stops = (0..n).map(|i| GradientStop::new(i as f32 / (n - 1) as f32, if i % 2 == 0 { ink } else { Color::WHITE })).collect(); - assert_eq!(g.ink_tints(&ink), None); + assert_eq!(g.ink_tints(&ink, &[]), None); assert!(!g.fits_ink_tints()); } diff --git a/crates/engine/src/tests/linked_stops.rs b/crates/engine/src/tests/linked_stops.rs index 61bc3a4d2..f3c44535a 100644 --- a/crates/engine/src/tests/linked_stops.rs +++ b/crates/engine/src/tests/linked_stops.rs @@ -188,10 +188,7 @@ fn spot_gradients_mixed_with_other_inks_export_as_device_n_shadings() { let (pdf, warnings) = pdf_of(&s); assert!(pdf.contains("/DeviceN[/Ink/Leaf]/DeviceCMYK"), "a DeviceN shading of both spot inks"); let functions: Vec<_> = pdf.match_indices("/C0[").filter_map(|(i, _)| pdf.get(i..i + 32)).collect(); - assert!( - pdf.contains("/C0[1 0]/C1[0.5019608 0.2509804]") && pdf.contains("/C0[0.5019608 0.2509804]/C1[0 0.5019608]"), - "the tints through the midpoint: {functions:?}" - ); + assert!(pdf.contains("/C0[1 0]/C1[0.5 0.25]") && pdf.contains("/C0[0.5 0.25]/C1[0 0.5]"), "the tints through the midpoint: {functions:?}"); assert!(!warnings.iter().any(|w| w.contains("spot")), "{warnings:?}"); } diff --git a/crates/pdf/src/export/exporter.rs b/crates/pdf/src/export/exporter.rs index a40e272b0..ed5f984c0 100644 --- a/crates/pdf/src/export/exporter.rs +++ b/crates/pdf/src/export/exporter.rs @@ -174,9 +174,10 @@ pub(super) fn q(v: f32) -> u8 { pub(super) fn color(c: &Color) -> krilla::color::Color { match *c { Color::Rgb { r, g, b } => rgb::Color::new(q(r), q(g), q(b)).into(), - Color::Cmyk { c, m, y, k } => cmyk::Color::new(q(c), q(m), q(y), q(k)).into(), + // Inks and grey keep their values: C75 is written as 0.75, not 191/255. + Color::Cmyk { c, m, y, k } => cmyk::Color::new_f32(c, m, y, k).into(), // VectorCraft grey is ink coverage (0 = white); PDF DeviceGray is lightness. - Color::Gray { k } => luma::Color::new(q(1.0 - k)).into(), + Color::Gray { k } => luma::Color::new_f32(1.0 - k).into(), Color::Lab { .. } => { let [r, g, b] = c.to_rgb(); rgb::Color::new(q(r), q(g), q(b)).into() diff --git a/crates/pdf/src/export/images.rs b/crates/pdf/src/export/images.rs index 79169dc14..390fcc344 100644 --- a/crates/pdf/src/export/images.rs +++ b/crates/pdf/src/export/images.rs @@ -91,7 +91,7 @@ impl Exporter<'_> { swatch.as_deref().and_then(|name| self.spot_ink(name)).into_iter().collect() }; let spot_colorants = - inks.into_iter().map(|(name, full)| Colorant::Spot { name, alternate: cmyk::Color::new(q(full[0]), q(full[1]), q(full[2]), q(full[3])) }); + inks.into_iter().map(|(name, full)| Colorant::Spot { name, alternate: cmyk::Color::new_f32(full[0], full[1], full[2], full[3]) }); let device_n = if registration { DeviceNSpace::new(Process::ALL.into_iter().map(Colorant::Process).chain(spot_colorants)) } else { diff --git a/crates/pdf/src/export/paint.rs b/crates/pdf/src/export/paint.rs index b68066ef6..06c08bc2d 100644 --- a/crates/pdf/src/export/paint.rs +++ b/crates/pdf/src/export/paint.rs @@ -32,7 +32,7 @@ impl Exporter<'_> { /// options use CMYK values); `None` when `name` isn't a spot colour. fn separation(&mut self, name: &str) -> Option { let (ink, full) = self.spot_ink(name)?; - let alt = krilla::color::RegularColor::Cmyk(cmyk::Color::new(q(full[0]), q(full[1]), q(full[2]), q(full[3]))); + let alt = krilla::color::RegularColor::Cmyk(cmyk::Color::new_f32(full[0], full[1], full[2], full[3])); Some(SeparationSpace::new(SeparationColorant::Custom(ink), alt)) } @@ -55,10 +55,10 @@ impl Exporter<'_> { fn solid(&mut self, c: &Color, link: Option<&str>, tint: f32) -> krilla::color::Color { if link == Some(vectorcraft_color::swatch::REGISTRATION) { let alt = krilla::color::RegularColor::Cmyk(cmyk::Color::new(255, 255, 255, 255)); - return SepColor::new(q(tint), SeparationSpace::new(SeparationColorant::AllColorants, alt)).into(); + return SepColor::new_f32(tint, SeparationSpace::new(SeparationColorant::AllColorants, alt)).into(); } match link.and_then(|n| self.separation(n)) { - Some(space) => SepColor::new(q(tint), space).into(), + Some(space) => SepColor::new_f32(tint, space).into(), None => self.col(c), } } @@ -82,7 +82,7 @@ impl Exporter<'_> { return self.device_n_stops(g); }; let space = self.separation(ink)?; - let tint = |t: f32| -> krilla::color::Color { SepColor::new(q(t), space.clone()).into() }; + let tint = |t: f32| -> krilla::color::Color { SepColor::new_f32(t, space.clone()).into() }; let mut out = vec![]; for (i, s) in g.stops.iter().enumerate() { out.push((s.offset, tint(tints[i]), s.opacity)); @@ -138,17 +138,18 @@ impl Exporter<'_> { } inks.push(v); } - let process: Vec = (0..4).filter(|&i| inks.iter().any(|v| q(v[i]) > 0)).collect(); - let colorants = process.iter().map(|&i| Colorant::Process(Process::ALL[i])).chain( - spots.iter().map(|(name, f)| Colorant::Spot { name: name.clone(), alternate: cmyk::Color::new(q(f[0]), q(f[1]), q(f[2]), q(f[3])) }), - ); + let process: Vec = (0..4).filter(|&i| inks.iter().any(|v| v[i] > 0.0)).collect(); + let colorants = process + .iter() + .map(|&i| Colorant::Process(Process::ALL[i])) + .chain(spots.iter().map(|(name, f)| Colorant::Spot { name: name.clone(), alternate: cmyk::Color::new_f32(f[0], f[1], f[2], f[3]) })); let Some(space) = DeviceNSpace::new(colorants) else { self.warn("gradients whose spot inks can't share a DeviceN color space are exported without the spot inks"); return None; }; let color = |v: &[f32]| -> Option { - let tints = process.iter().map(|&i| q(v[i])).chain(v[4..].iter().map(|&t| q(t))).collect(); - Some(DnColor::new(tints, space.clone())?.into()) + let tints = process.iter().map(|&i| v[i]).chain(v[4..].iter().copied()).collect(); + Some(DnColor::new_f32(tints, space.clone())?.into()) }; // A Perceptual gradient: the stops on its curve, each a mix of two stops' inks. if let Some(curve) = g.ink_mixes() { @@ -346,10 +347,10 @@ impl Exporter<'_> { Some(krilla::paint::Pattern { stream: sb.finish(), transform: xf(at), width: pw as f32, height: ph as f32 }.into()) } - /// Is stop `s` paper white: white, unlinked or linked to a swatch that isn't a spot color (a - /// white spot color is an ink of its own)? It adds no ink to a Separation or DeviceN shading. + /// Is stop `s` paper white ([`paper`]), unlinked or linked to a swatch that isn't a spot color + /// (a white spot color is an ink of its own)? It adds no ink to a Separation or DeviceN shading. fn paper_stop(&self, s: &vectorcraft_color::GradientStop) -> bool { - !s.swatch.as_deref().is_some_and(|n| self.doc.swatch(n).is_some_and(|w| w.spot)) && s.color.to_rgba8(1.0)[..3] == [255; 3] + !s.swatch.as_deref().is_some_and(|n| self.doc.swatch(n).is_some_and(|w| w.spot)) && paper(&s.color) } /// The spot swatch whose tints are every stop of `g` that isn't paper white @@ -401,7 +402,9 @@ impl Exporter<'_> { let doc = self.doc; let full = doc.linked_color(doc.swatch(ink)?.paint.color()?, true); let space = self.separation(ink)?; - Some(g.ink_tints(&full)?.into_iter().map(|(o, t, a)| (o, SepColor::new(q(t), space.clone()).into(), a)).collect()) + // The stops keep their own tints; paper white is 0% of the ink. + let own: Vec = g.stops.iter().map(|s| if s.swatch.as_deref() == Some(ink) { s.tint } else { 0.0 }).collect(); + Some(g.ink_tints(&full, &own)?.into_iter().map(|(o, t, a)| (o, SepColor::new_f32(t, space.clone()).into(), a)).collect()) } /// Warn when gradient `g`, drawn as an image, has stops linked to a spot swatch: the image is @@ -457,3 +460,12 @@ impl Exporter<'_> { s.pop(); } } + +/// Is `c` paper white: no ink in CMYK or gray, white in RGB or Lab? +fn paper(c: &Color) -> bool { + match *c { + Color::Cmyk { c, m, y, k } => c <= 0.0 && m <= 0.0 && y <= 0.0 && k <= 0.0, + Color::Gray { k } => k <= 0.0, + _ => c.to_rgba8(1.0)[..3] == [255; 3], + } +} diff --git a/crates/pdf/src/tests/colorize.rs b/crates/pdf/src/tests/colorize.rs index a3cf825ab..91ee3d5f2 100644 --- a/crates/pdf/src/tests/colorize.rs +++ b/crates/pdf/src/tests/colorize.rs @@ -65,6 +65,17 @@ fn a_spot_color_writes_the_image_in_its_ink() { assert!(String::from_utf8_lossy(&out).contains("/Separation/Ink"), "the ink's Separation space"); } +#[test] +fn a_spot_colorized_image_keeps_the_ink_values_of_its_swatch() { + let spot = Paint::Solid { color: Color::cmyk(0.0, 0.75, 0.75, 0.1), swatch: Some("Ink".into()), tint: 1.0 }; + let mut d = doc(ColorMode::Cmyk, Some(spot)); + let Some(ink) = d.swatches.iter_mut().find(|s| s.name == "Ink") else { panic!("the Ink swatch") }; + ink.paint = Paint::solid(Color::cmyk(0.0, 0.75, 0.75, 0.1)); + let text = String::from_utf8_lossy(&pdf(&d)).into_owned(); + assert!(text.contains("/Separation/Ink/DeviceCMYK"), "{text}"); + assert!(text.contains("/C1[0 0.75 0.75 0.1]"), "M75 Y75 K10, not 191/255 and 26/255: {text}"); +} + #[test] fn a_process_color_writes_the_image_in_that_color_with_a_soft_mask() { let out = pdf(&doc(ColorMode::Cmyk, Some(Paint::solid(Color::cmyk(1.0, 0.0, 0.0, 0.0))))); diff --git a/crates/pdf/src/tests/output.rs b/crates/pdf/src/tests/output.rs index 26d65fd19..36eb939ff 100644 --- a/crates/pdf/src/tests/output.rs +++ b/crates/pdf/src/tests/output.rs @@ -8,7 +8,7 @@ use craft_pdf::syntax::Pdf; use craft_pdf::syntax::object::{Array, Dict, Name, ObjectIdentifier, Stream}; use serde_json::{Value, json}; use vectorcraft_color::cms::{self, Cms, ColorSettings, DEVICE_CMYK, GENERIC_CMYK, SRGB}; -use vectorcraft_color::{Color, Paint}; +use vectorcraft_color::{Color, Gradient, GradientKind, GradientMethod, GradientPaint, GradientStop, Paint, Swatch}; use vectorcraft_doc::{Appearance, ColorMode, Document, ImageBlob, ImageObject, Node, NodeKind}; use vectorcraft_geom::{Affine, Rect, shapes}; @@ -54,7 +54,7 @@ fn colours(pdf: &[u8]) -> Vec<(String, Vec)> { out } -/// `v` as the writer stores it (8 bits a component). +/// RGB `v` as the writer stores it (8 bits a component). CMYK and grey are written as they are. fn q8(v: &[f32]) -> Vec { v.iter().map(|v| (v.clamp(0.0, 1.0) * 255.0).round() / 255.0).collect() } @@ -89,7 +89,7 @@ fn converting_to_a_cmyk_destination_writes_k_instead_of_rg() { let red = Color::rgb(1.0, 0.0, 0.0); let d = doc(ColorMode::Rgb, &[red]); assert_eq!(colours(&pdf(&d, json!({})).bytes), [("rg".to_string(), vec![1.0, 0.0, 0.0])], "without conversion"); - let ink = q8(&to_cmyk(GENERIC_CMYK).srgb_to_cmyk([1.0, 0.0, 0.0], cms::active_settings().intent)); + let ink = to_cmyk(GENERIC_CMYK).srgb_to_cmyk([1.0, 0.0, 0.0], cms::active_settings().intent); for conversion in ["destination", "preserveNumbers"] { let r = pdf(&d, json!({"output": {"conversion": conversion, "destination": GENERIC_CMYK}})); let c = colours(&r.bytes); @@ -104,15 +104,15 @@ fn converting_to_a_cmyk_destination_writes_k_instead_of_rg() { fn preserve_numbers_keeps_cmyk_values_that_destination_converts() { let ink = Color::cmyk(0.1, 0.2, 0.3, 0.4); let d = doc(ColorMode::Rgb, &[ink, Color::Gray { k: 0.25 }]); - let numbers = q8(&[0.1, 0.2, 0.3, 0.4]); + let numbers = [0.1, 0.2, 0.3, 0.4]; let fills = |conversion: &str, dest: &str| colours(&pdf(&d, json!({"output": {"conversion": conversion, "destination": dest}})).bytes); let kept = fills("preserveNumbers", DEVICE_CMYK); assert!(kept[0].0 == "k" && close(&kept[0].1, &numbers), "{kept:?}"); // Grey stays grey. - assert!(kept[1].0 == "g" && close(&kept[1].1, &q8(&[0.75])), "{kept:?}"); + assert!(kept[1].0 == "g" && close(&kept[1].1, &[0.75]), "{kept:?}"); // Convert to destination: CMYK of another profile goes through Lab into the destination. let converted = fills("destination", DEVICE_CMYK); - let expected = q8(&to_cmyk(DEVICE_CMYK).lab_to_cmyk(cms::active().lab(&ink), cms::active_settings().intent)); + let expected = to_cmyk(DEVICE_CMYK).lab_to_cmyk(cms::active().lab(&ink), cms::active_settings().intent); assert!(converted[0].0 == "k" && close(&converted[0].1, &expected), "{converted:?} vs {expected:?}"); assert!(converted[0].1.iter().zip(&numbers).any(|(a, b)| (a - b).abs() > 0.02), "the numbers change: {converted:?}"); // …unless the destination is their own profile. @@ -120,6 +120,100 @@ fn preserve_numbers_keeps_cmyk_values_that_destination_converts() { assert!(close(&own[0].1, &numbers), "{own:?}"); } +#[test] +fn cmyk_inks_grey_and_spot_tints_are_written_as_the_document_holds_them() { + let fills = [Color::cmyk(0.75, 0.0, 1.0, 0.0), Color::cmyk(0.04, 0.04, 0.04, 0.04), Color::Gray { k: 0.04 }]; + let mut d = doc(ColorMode::Cmyk, &fills); + d.swatches.push(Swatch { name: "Gold".into(), paint: Paint::solid(Color::cmyk(0.0, 0.2, 0.8, 0.1)), global: true, spot: true }); + let gold = Paint::Solid { color: Color::cmyk(0.0, 0.15, 0.6, 0.075), swatch: Some("Gold".into()), tint: 0.75 }; + let l = d.layers[0].id; + let n = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(100.0, 10.0, 120.0, 30.0)), Appearance::basic(gold, Paint::None, 0.0)); + d.insert(Some(l), 3, n).unwrap(); + let c = colours(&pdf(&d, json!({})).bytes); + let expected = [("k", vec![0.75, 0.0, 1.0, 0.0]), ("k", vec![0.04; 4]), ("g", vec![1.0 - 0.04]), ("scn", vec![0.75])]; + assert_eq!(c, expected.map(|(op, v)| (op.to_string(), v)), "C75 is 0.75, not 191/255"); +} + +#[test] +fn spot_alternates_and_device_n_gradient_inks_are_written_as_the_document_holds_them() { + let mut d = doc(ColorMode::Cmyk, &[]); + d.swatches.push(Swatch { name: "Gold".into(), paint: Paint::solid(Color::cmyk(0.0, 0.2, 0.8, 0.1)), global: true, spot: true }); + let mut gold = GradientStop::new(0.0, Color::cmyk(0.0, 0.2, 0.8, 0.1)); + gold.swatch = Some("Gold".into()); + gold.tint = 0.75; + let g = GradientPaint::new(Gradient::new(GradientKind::Linear, vec![gold, GradientStop::new(1.0, Color::cmyk(0.75, 0.0, 0.0, 0.04))])); + let l = d.layers[0].id; + let n = Node::path( + d.alloc_id(), + shapes::rectangle(Rect::new(10.0, 10.0, 110.0, 30.0)), + Appearance::basic(Paint::Gradient(Box::new(g)), Paint::None, 0.0), + ); + d.insert(Some(l), 0, n).unwrap(); + // A solid fill in the spot color, in its own Separation space. + let solid = Paint::Solid { color: Color::cmyk(0.0, 0.15, 0.6, 0.075), swatch: Some("Gold".into()), tint: 0.75 }; + let n = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(10.0, 40.0, 30.0, 60.0)), Appearance::basic(solid, Paint::None, 0.0)); + d.insert(Some(l), 1, n).unwrap(); + let text = String::from_utf8_lossy(&export(&d, &PdfOptions::uncompressed()).unwrap()).into_owned(); + let exact = "[/Separation/Gold/DeviceCMYK<>]"; + let spaces = text.matches("[/Separation/Gold/").count(); + assert!(spaces > 0 && text.matches(exact).count() == spaces, "K10 is 0.1, not 26/255, in every Gold space: {text}"); + assert!(text.contains("[/DeviceN[/Cyan/Black/Gold]"), "{text}"); + assert!(text.contains("/C0[0 0 0.75]/C1[0.75 0.04 0]"), "the stops' C75, K4 and 75% Gold are written as they are"); +} + +/// The uncompressed PDF of a CMYK document whose rectangle is filled with gradient `stops` +/// (Perceptual when `perceptual`), with spot swatch "Gold" (C0 M20 Y80 K10). +fn gold_gradient_pdf(stops: Vec, perceptual: bool) -> String { + let mut d = doc(ColorMode::Cmyk, &[]); + d.swatches.push(Swatch { name: "Gold".into(), paint: Paint::solid(Color::cmyk(0.0, 0.2, 0.8, 0.1)), global: true, spot: true }); + let mut g = Gradient::new(GradientKind::Linear, stops); + if perceptual { + g.method = GradientMethod::Perceptual; + } + let paint = Paint::Gradient(Box::new(GradientPaint::new(g))); + let l = d.layers[0].id; + let n = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 110.0, 30.0)), Appearance::basic(paint, Paint::None, 0.0)); + d.insert(Some(l), 0, n).unwrap(); + String::from_utf8_lossy(&export(&d, &PdfOptions::uncompressed()).unwrap()).into_owned() +} + +/// A gradient stop at `offset` linked to "Gold" at `tint`. +fn gold_stop(offset: f32, tint: f32) -> GradientStop { + let mut s = GradientStop::new(offset, Color::cmyk(0.0, 0.2, 0.8, 0.1).tinted(tint)); + s.swatch = Some("Gold".into()); + s.tint = tint; + s +} + +#[test] +fn perceptual_spot_gradient_stops_keep_their_own_tints() { + let paper = GradientStop::new(1.0, Color::cmyk(0.0, 0.0, 0.0, 0.0)); + let text = gold_gradient_pdf(vec![gold_stop(0.0, 0.8), gold_stop(0.5, 0.4), paper], true); + assert!(text.contains("/FunctionType 3"), "tints on the curve: {text}"); + // The one-tint ends of the stitched functions, in order along the gradient. + let ends = |key: &str| -> Vec { + text.match_indices(key) + .filter_map(|(i, _)| text.get(i + key.len()..)?.split(']').next()) + .filter(|v| !v.contains(' ')) + .map(str::to_string) + .collect() + }; + let (c0, c1) = (ends("/C0["), ends("/C1[")); + assert_eq!(c0.first().map(String::as_str), Some("0.8"), "the first stop's 80%: {c0:?}"); + assert_eq!(c1.last().map(String::as_str), Some("0"), "paper white adds no ink: {c1:?}"); + assert!(c1.iter().any(|v| v == "0.4") && c0.iter().any(|v| v == "0.4"), "the middle stop's 40%: {c0:?} {c1:?}"); +} + +#[test] +fn spot_gradients_keep_a_trace_of_process_ink_at_a_light_stop() { + for light in [Color::cmyk(0.0, 0.0, 0.0, 0.001), Color::Gray { k: 0.001 }] { + for perceptual in [false, true] { + let text = gold_gradient_pdf(vec![gold_stop(0.0, 1.0), GradientStop::new(1.0, light)], perceptual); + assert!(text.contains("[/DeviceN[/Black/Gold]"), "{light:?}, perceptual {perceptual}: K 0.1% is ink, not paper"); + } + } +} + #[test] fn converting_to_rgb_turns_cmyk_into_rg() { let ink = Color::cmyk(0.0, 1.0, 1.0, 0.0); @@ -380,9 +474,9 @@ fn colours_are_in_the_profiles_assigned_to_the_document() { assert!(icc_streams(&r.bytes) == [own.to_vec()], "tagged with the document's CMYK profile, not the working one"); output_intent(&r.bytes, None, |oi| assert_eq!(oi.get::>(b"DestOutputProfile").unwrap().decoded().unwrap().as_ref(), &*own)); let c = colours(&r.bytes); - assert!(c[0].0 == "scn" && close(&c[0].1, &q8(&[0.1, 0.2, 0.3, 0.4])), "CMYK keeps its numbers: {c:?}"); - let separated = q8(&to_cmyk(DEVICE_CMYK).to_cmyk(&grey, intent)); - assert!(!close(&separated, &q8(&cms::active().to_cmyk(&grey, intent))), "the two profiles separate grey differently"); + assert!(c[0].0 == "scn" && close(&c[0].1, &[0.1, 0.2, 0.3, 0.4]), "CMYK keeps its numbers: {c:?}"); + let separated = to_cmyk(DEVICE_CMYK).to_cmyk(&grey, intent); + assert!(!close(&separated, &cms::active().to_cmyk(&grey, intent)), "the two profiles separate grey differently"); assert!(c[1].0 == "scn" && close(&c[1].1, &separated), "RGB separated through the document's profile: {c:?} vs {separated:?}"); // A profile that isn't available leaves the working space, with a warning. d.color_profiles.cmyk = Some("No Such Press".into()); diff --git a/crates/pdf/src/tests/printadvanced.rs b/crates/pdf/src/tests/printadvanced.rs index d1ae7c2e3..f9dacb759 100644 --- a/crates/pdf/src/tests/printadvanced.rs +++ b/crates/pdf/src/tests/printadvanced.rs @@ -63,11 +63,6 @@ fn colours(pdf: &[u8], page: usize) -> Vec<(String, Vec)> { out } -/// `v` as the writer stores it (8 bits a component). -fn q8(v: &[f32]) -> Vec { - 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) } @@ -119,13 +114,13 @@ fn a_cmyk_printer_profile_writes_device_cmyk_with_the_intent() { 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)); + let ink = 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:?}"); + assert!(c[0].0 == "k" && close(&c[0].1, &[0.1, 0.2, 0.3, 0.4]), "{c:?}"); } #[test] diff --git a/docs/pdf-backend.md b/docs/pdf-backend.md index 4aca4353a..fb97151ca 100644 --- a/docs/pdf-backend.md +++ b/docs/pdf-backend.md @@ -15,8 +15,9 @@ Editable import keeps its VectorCraft adapter, layer routing, colour policy and Desktop printer submission remains in `craft-print`. The shared writer vendors Krilla 0.8.2 with an unwind-safe cleanup and poisoning patch. -The root `[patch.crates-io]` selects that copy from the same immutable revision: Cargo does -not inherit a dependency workspace's patches. A failed page or surface cleanup poisons the +The root `[patch.crates-io]` selects that copy from craft-libs revision +`fb08be5fa19eee8d37e624397fcdc96df6035e92`, which adds craft-libs#417 (CMYK, gray and spot +colors keep their exact inks): Cargo does not inherit a dependency workspace's patches. A failed page or surface cleanup poisons the backend document, so serialization cannot return an incomplete PDF after unwinding. The shared crate's compile-time backend marker and `cargo xtask pdf-deps` (included in CI) check that the patched writer is used. Krilla retains its MIT OR Apache-2.0 licensing; @@ -51,9 +52,9 @@ warnings also become errors before a document is returned. Whitespace and commen EOF remain valid. The typed iterator does not classify every malformed operator, so this is known-incomplete rejection rather than a general PDF validity certificate. -The five patched Hayro crates and Krilla are selected in the root `[patch.crates-io]` -from the same immutable revision as `craft-pdf`. `cargo xtask pdf-deps` rejects missing, -duplicate, local-path, registry and mismatched revisions for those seven packages. +The five patched Hayro crates are selected in the root `[patch.crates-io]` from the same +immutable revision as `craft-pdf`, and Krilla from the revision named above. `cargo xtask pdf-deps` +rejects missing, duplicate, local-path, registry and mismatched revisions for those seven packages. `craft-print`, `craft-ui` and the other craft-libs crates use the same revision. Dependency asset hashes and sources are in `ASSETS.md`; redistributed license texts are diff --git a/xtask/src/pdf_deps.rs b/xtask/src/pdf_deps.rs index be9458616..6e5992062 100644 --- a/xtask/src/pdf_deps.rs +++ b/xtask/src/pdf_deps.rs @@ -6,16 +6,23 @@ use std::process::Command; use serde_json::Value; const REV: &str = "92d392cff132eaf53e13afff77f38b5c11b73aa0"; -const PACKAGES: &[(&str, &str)] = &[ - ("craft-pdf", "0.1.0"), - ("krilla", "0.8.2"), - ("hayro-syntax", "0.7.2"), - ("hayro-interpret", "0.7.0"), - ("hayro-jbig2", "0.3.0"), - ("hayro-cmap", "0.1.0"), - ("hayro-postscript", "0.1.0"), +/// The writer's revision: craft-libs#417, CMYK, gray and spot colors with their exact inks. +const KRILLA_REV: &str = "fb08be5fa19eee8d37e624397fcdc96df6035e92"; +const PACKAGES: &[(&str, &str, &str)] = &[ + ("craft-pdf", "0.1.0", REV), + ("krilla", "0.8.2", KRILLA_REV), + ("hayro-syntax", "0.7.2", REV), + ("hayro-interpret", "0.7.0", REV), + ("hayro-jbig2", "0.3.0", REV), + ("hayro-cmap", "0.1.0", REV), + ("hayro-postscript", "0.1.0", REV), ]; +/// The resolved source of a craft-libs package at `rev`. +fn source(rev: &str) -> String { + format!("git+https://github.com/storytold/craft-libs?rev={rev}#{rev}") +} + pub fn run(root: &Path) -> Result<(), String> { let output = Command::new(std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into())) .current_dir(root) @@ -27,14 +34,14 @@ pub fn run(root: &Path) -> Result<(), String> { } let metadata: Value = serde_json::from_slice(&output.stdout).map_err(|e| format!("cargo metadata: {e}"))?; validate(&metadata)?; - println!("PDF: shared crate and patched backends resolve once to {REV}"); + println!("PDF: shared crate and patched backends resolve once to {REV} (krilla to {KRILLA_REV})"); Ok(()) } fn validate(metadata: &Value) -> Result<(), String> { let packages = metadata.get("packages").and_then(Value::as_array).ok_or("cargo metadata has no packages")?; - let source = format!("git+https://github.com/storytold/craft-libs?rev={REV}#{REV}"); - for &(name, version) in PACKAGES { + for &(name, version, rev) in PACKAGES { + let source = source(rev); let mut found = packages.iter().filter(|p| p.get("name").and_then(Value::as_str) == Some(name)); let package = found.next().ok_or_else(|| format!("{name}: missing resolved package"))?; if found.next().is_some() { @@ -53,15 +60,7 @@ mod tests { use serde_json::json; fn metadata() -> Value { - let packages: Vec<_> = PACKAGES - .iter() - .map(|(name, version)| { - json!({ - "name":name, "version":version, - "source":format!("git+https://github.com/storytold/craft-libs?rev={REV}#{REV}") - }) - }) - .collect(); + let packages: Vec<_> = PACKAGES.iter().map(|(name, version, rev)| json!({"name":name, "version":version, "source":source(rev)})).collect(); json!({"packages": packages}) } @@ -95,6 +94,7 @@ mod tests { ("source", Value::Null), ("source", json!("registry+https://github.com/rust-lang/crates.io-index")), ("source", json!("git+https://github.com/storytold/craft-libs?branch=main#other")), + ("source", json!(source(if PACKAGES[index].2 == REV { KRILLA_REV } else { REV }))), ("version", json!("0.99.0")), ] { let mut m = metadata();