Merge pull request #5080 from dotCooCoo/fix/issue-3118

Fix #3118: PDF export writes CMYK inks, gray and spot tints as the document holds them
This commit is contained in:
BFlat
2026-11-02 16:05:58 +07:00
committed by GitHub
13 changed files with 202 additions and 79 deletions
Generated
+1 -1
View File
@@ -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",
+3 -1
View File
@@ -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" }
+18 -12
View File
@@ -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<Item = (f32, Color, f32, Option<f32>)> + '_ {
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<Item = (f32, Color, f32, Option<f32>, Option<usize>)> + '_ {
// 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<Vec<(f32, f32, f32)>> {
/// 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<Vec<(f32, f32, f32)>> {
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?
+7 -3
View File
@@ -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());
}
+1 -4
View File
@@ -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:?}");
}
+3 -2
View File
@@ -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()
+1 -1
View File
@@ -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 {
+26 -14
View File
@@ -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<SeparationSpace> {
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<usize> = (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<usize> = (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<krilla::color::Color> {
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<f32> = 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],
}
}
+11
View File
@@ -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)))));
+103 -9
View File
@@ -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<f32>)> {
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<f32> {
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<</FunctionType 2/Domain[0 1]/Range[0 1 0 1 0 1 0 1]/C0[0 0 0 0]/C1[0 0.2 0.8 0.1]/N 1>>]";
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<GradientStop>, 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<String> {
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::<Stream<'_>>(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());
+2 -7
View File
@@ -63,11 +63,6 @@ fn colours(pdf: &[u8], page: usize) -> Vec<(String, Vec<f32>)> {
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)
}
@@ -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]
+6 -5
View File
@@ -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
+20 -20
View File
@@ -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();