mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-28 20:26:57 -06:00
Print color: spot gradients export as Separation and DeviceN shadings
This commit is contained in:
@@ -348,6 +348,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m
|
||||
|
||||
Newest first.
|
||||
|
||||
- **2026-10-20** · Spot gradients print on their plates. A gradient stop keeps the swatch it was given (Gradient panel drop, `swatch.create {gradient}`, `object.gradient`, IDML `StopColor`), following its renames and edits. PDF export shades one spot with its tints or [Paper] as a Separation, and a spot with process inks or a second spot as DeviceN; a process gradient to [Paper] prints in DeviceCMYK. Documents with plain-colour stops export as before. No readiness percentage change.
|
||||
- **2026-10-16** · Type › Reload Fonts (`font.reload`): fonts installed, edited or removed while DesignCraft runs are read again without a restart, and the open documents recompose with them (#328, #547). An edited font file is noticed by its length and modification time; bundled, added and document fonts stay as they are. InDesign does this by itself, so noticing changes automatically is still open (gap 6). No readiness percentage change.
|
||||
- **2026-10-16** · Parents can be deleted from their Pages-panel context menu or `layout.parents.delete {id}`. Applied-page and based-on references are cleared, local override copies survive, and Undo restores the parent. Addresses the deletion portion of #956; no readiness percentage change.
|
||||
- **2026-10-17** · File › Package writes the document's and the IDML's links relative to the package (`Links/logo.png`, `file:Links/logo.png`), so a package holds no path from the machine that made it; opening a document resolves them to the files beside it. Links › Copy Link(s) To still links by full path. (#340). No readiness percentage change.
|
||||
|
||||
+45
-36
@@ -545,11 +545,11 @@ pub(crate) fn norm(v: f32) -> NormalizedF32 {
|
||||
NormalizedF32::new(if v.is_finite() { v.clamp(0.0, 1.0) } else { 1.0 }).unwrap_or(NormalizedF32::ONE)
|
||||
}
|
||||
|
||||
fn q(v: f32) -> u8 {
|
||||
pub(crate) fn q(v: f32) -> u8 {
|
||||
(v.clamp(0.0, 1.0) * 255.0).round() as u8
|
||||
}
|
||||
|
||||
fn regular(c: &Color, rgb_only: bool) -> krilla::color::RegularColor {
|
||||
pub(crate) fn regular(c: &Color, rgb_only: bool) -> krilla::color::RegularColor {
|
||||
use krilla::color::RegularColor as R;
|
||||
if rgb_only && matches!(c, Color::Cmyk { .. }) {
|
||||
let [r, g, b] = c.to_rgb();
|
||||
@@ -1061,41 +1061,10 @@ 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 mut expanded = g.expanded_stops();
|
||||
// Grey and CMYK stops blend as inks, so a grey stop beside a CMYK one is the K ink.
|
||||
if !self.rgb_only && expanded.iter().any(|(_, c, _)| matches!(c, Color::Cmyk { .. })) {
|
||||
for (_, c, _) in &mut expanded {
|
||||
if let Color::Gray { k } = *c {
|
||||
*c = Color::cmyk(0.0, 0.0, 0.0, k);
|
||||
}
|
||||
}
|
||||
}
|
||||
// A PDF shading needs one colour space. Midpoint expansion can introduce RGB even
|
||||
// when every authored stop is CMYK/Gray; PDF/A can also change the output space.
|
||||
let components = |c: &Color| match c {
|
||||
Color::Cmyk { .. } if !self.rgb_only => 4,
|
||||
Color::Gray { .. } => 1,
|
||||
_ => 3,
|
||||
let stops = match self.ink_gradient_stops(g, swatch) {
|
||||
Some(stops) => stops,
|
||||
None => self.device_gradient_stops(g, swatch),
|
||||
};
|
||||
let mixed = expanded.first().is_some_and(|(_, first, _)| expanded.iter().any(|(_, c, _)| components(c) != components(first)));
|
||||
if mixed {
|
||||
self.warn(format!("gradient '{swatch}': mixed color spaces converted to RGB (colour appearance and separations may change)"));
|
||||
}
|
||||
let mut stops: Vec<Stop> = Vec::new();
|
||||
let mut last = 0.0f32;
|
||||
for (o, c, a) in expanded {
|
||||
let o = o.clamp(last, 1.0);
|
||||
last = o;
|
||||
let color = if mixed && !matches!(c, Color::Rgb { .. }) {
|
||||
let [r, g, b] = c.to_rgb();
|
||||
rgb::Color::new(q(r), q(g), q(b)).into()
|
||||
} else {
|
||||
// Preserve RGB values, including already-interpolated display RGB, and
|
||||
// leave every homogeneous gradient on its existing device-space path.
|
||||
device(&c, self.rgb_only)
|
||||
};
|
||||
stops.push(Stop { offset: norm(o), color, opacity: norm(a) });
|
||||
}
|
||||
if stops.is_empty() {
|
||||
return None;
|
||||
}
|
||||
@@ -1139,6 +1108,46 @@ impl Exporter<'_> {
|
||||
self.swatch_color(swatch, tint).map(Into::into)
|
||||
}
|
||||
|
||||
/// A gradient's stops in device colours: CMYK, RGB or Gray, as one space.
|
||||
fn device_gradient_stops(&mut self, g: &designcraft_color::Gradient, swatch: &str) -> Vec<Stop> {
|
||||
let mut expanded = g.expanded_stops();
|
||||
// Grey and CMYK stops blend as inks, so a grey stop beside a CMYK one is the K ink.
|
||||
if !self.rgb_only && expanded.iter().any(|(_, c, _)| matches!(c, Color::Cmyk { .. })) {
|
||||
for (_, c, _) in &mut expanded {
|
||||
if let Color::Gray { k } = *c {
|
||||
*c = Color::cmyk(0.0, 0.0, 0.0, k);
|
||||
}
|
||||
}
|
||||
}
|
||||
// A PDF shading needs one colour space. Midpoint expansion can introduce RGB even
|
||||
// when every authored stop is CMYK/Gray; PDF/A can also change the output space.
|
||||
let components = |c: &Color| match c {
|
||||
Color::Cmyk { .. } if !self.rgb_only => 4,
|
||||
Color::Gray { .. } => 1,
|
||||
_ => 3,
|
||||
};
|
||||
let mixed = expanded.first().is_some_and(|(_, first, _)| expanded.iter().any(|(_, c, _)| components(c) != components(first)));
|
||||
if mixed {
|
||||
self.warn(format!("gradient '{swatch}': mixed color spaces converted to RGB (colour appearance and separations may change)"));
|
||||
}
|
||||
let mut stops: Vec<Stop> = Vec::new();
|
||||
let mut last = 0.0f32;
|
||||
for (o, c, a) in expanded {
|
||||
let o = o.clamp(last, 1.0);
|
||||
last = o;
|
||||
let color = if mixed && !matches!(c, Color::Rgb { .. }) {
|
||||
let [r, g, b] = c.to_rgb();
|
||||
rgb::Color::new(q(r), q(g), q(b)).into()
|
||||
} else {
|
||||
// Preserve RGB values, including already-interpolated display RGB, and
|
||||
// leave every homogeneous gradient on its existing device-space path.
|
||||
device(&c, self.rgb_only)
|
||||
};
|
||||
stops.push(Stop { offset: norm(o), color, opacity: norm(a) });
|
||||
}
|
||||
stops
|
||||
}
|
||||
|
||||
fn stroke(&mut self, s: &mut Surface, it: &Item, bp: &BezPath, path: &Path) {
|
||||
let st = &it.stroke;
|
||||
// A gradient swatch runs across the path's box, as on a fill.
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
//! 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.
|
||||
|
||||
use craft_pdf::write::backend as krilla;
|
||||
use designcraft_color::swatch::{ColorType, SwatchValue, apply_tint};
|
||||
use designcraft_color::{Color, Gradient};
|
||||
use krilla::color::device_n::{self, Colorant, DeviceNSpace, Process};
|
||||
use krilla::color::separation::{self, SeparationColorant, SeparationSpace};
|
||||
use krilla::paint::Stop;
|
||||
|
||||
use crate::export::{Exporter, norm, q, regular};
|
||||
|
||||
/// A stop as inks.
|
||||
enum Ink {
|
||||
/// CMYK tints (0..=1); `[Paper]` is all zero.
|
||||
Process([f32; 4]),
|
||||
Spot {
|
||||
name: String,
|
||||
alternate: Color,
|
||||
tint: f32,
|
||||
},
|
||||
}
|
||||
|
||||
/// Inks of a plain colour: CMYK as it is, grey as black. RGB has none.
|
||||
fn process_inks(c: Color) -> Option<[f32; 4]> {
|
||||
let tint = |v: f32| if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 };
|
||||
match c {
|
||||
Color::Cmyk { c, m, y, k } => Some([tint(c), tint(m), tint(y), tint(k)]),
|
||||
Color::Gray { k } => Some([0.0, 0.0, 0.0, tint(k)]),
|
||||
Color::Rgb { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The shading's color space.
|
||||
enum Space {
|
||||
Separation(SeparationSpace),
|
||||
DeviceN(DeviceNSpace),
|
||||
}
|
||||
|
||||
impl Exporter<'_> {
|
||||
/// The inks a stop prints with, following tints and the Ink Manager as solid fills do.
|
||||
/// `None` when they can't go into an ink shading (RGB, `[Registration]`).
|
||||
fn stop_ink(&self, stop: &designcraft_color::GradientStop) -> Option<Ink> {
|
||||
let Some(mut n) = stop.swatch.as_deref() else { return process_inks(stop.color).map(Ink::Process) };
|
||||
let mut t = 1.0f32;
|
||||
for _ in 0..8 {
|
||||
// A swatch that's gone leaves the stop's own colour.
|
||||
let Some(sw) = self.doc.swatch(n) else { return process_inks(stop.color).map(Ink::Process) };
|
||||
match &sw.value {
|
||||
SwatchValue::Paper { .. } => return Some(Ink::Process([0.0; 4])),
|
||||
SwatchValue::Tint { base, tint } => {
|
||||
t *= tint;
|
||||
n = base;
|
||||
}
|
||||
SwatchValue::Color { color_type: ColorType::Spot, .. } => {
|
||||
let (ink, process) = self.doc.inks.resolve(&sw.name);
|
||||
let target =
|
||||
self.doc.swatch(ink).filter(|w| matches!(w.value, SwatchValue::Color { color_type: ColorType::Spot, .. })).unwrap_or(sw);
|
||||
let SwatchValue::Color { color, .. } = &target.value else { return None };
|
||||
if process {
|
||||
return process_inks(apply_tint(*color, t)).map(Ink::Process);
|
||||
}
|
||||
let tint = if t.is_finite() { t.clamp(0.0, 1.0) } else { 1.0 };
|
||||
return Some(Ink::Spot { name: target.name.clone(), alternate: *color, tint });
|
||||
}
|
||||
SwatchValue::Color { color, .. } => return process_inks(apply_tint(*color, t)).map(Ink::Process),
|
||||
SwatchValue::Registration | SwatchValue::None | SwatchValue::Gradient { .. } => return None,
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The gradient's stops in its inks, or `None` to write it in device colours (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<Vec<Stop>> {
|
||||
// Plain-colour stops (all older documents) keep their device colours.
|
||||
if g.stops.iter().all(|s| s.swatch.is_none()) {
|
||||
return None;
|
||||
}
|
||||
let inks: Vec<Option<Ink>> = 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) {
|
||||
return None;
|
||||
}
|
||||
let Some(inks) = inks.into_iter().collect::<Option<Vec<Ink>>>() else {
|
||||
return self.unshaded(swatch, "a stop has no inks (RGB or [Registration])", has_spot);
|
||||
};
|
||||
// Spots in the order the stops name them; process inks a stop sets.
|
||||
let mut spots: Vec<(String, Color)> = Vec::new();
|
||||
let mut process = [false; 4];
|
||||
for i in &inks {
|
||||
match i {
|
||||
Ink::Process(v) => {
|
||||
for (used, v) in process.iter_mut().zip(v) {
|
||||
*used |= *v > 1e-6;
|
||||
}
|
||||
}
|
||||
Ink::Spot { name, alternate, .. } => {
|
||||
if !spots.iter().any(|(n, _)| n == name) {
|
||||
spots.push((name.clone(), *alternate));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let process: Vec<usize> = (0..4).filter(|j| process.get(*j).copied().unwrap_or(false)).collect();
|
||||
let space = match (spots.as_slice(), process.is_empty()) {
|
||||
([], _) => None,
|
||||
([(name, alternate)], true) => {
|
||||
Some(Space::Separation(SeparationSpace::new(SeparationColorant::Custom(name.clone()), regular(alternate, self.rgb_only))))
|
||||
}
|
||||
_ => {
|
||||
if self.rgb_only {
|
||||
return self.unshaded(swatch, "DeviceN isn't allowed in PDF/A", true);
|
||||
}
|
||||
let mut colorants: Vec<Colorant> = process.iter().filter_map(|j| Process::ALL.get(*j).copied().map(Colorant::Process)).collect();
|
||||
for (name, alternate) in &spots {
|
||||
let Color::Cmyk { c, m, y, k } = *alternate else {
|
||||
return self.unshaded(swatch, &format!("'{name}' has no CMYK values"), true);
|
||||
};
|
||||
colorants.push(Colorant::Spot { name: name.clone(), alternate: krilla::color::cmyk::Color::new(q(c), q(m), q(y), q(k)) });
|
||||
}
|
||||
match DeviceNSpace::new(colorants) {
|
||||
Some(s) => Some(Space::DeviceN(s)),
|
||||
None => return self.unshaded(swatch, "its inks don't fit one DeviceN space", true),
|
||||
}
|
||||
}
|
||||
};
|
||||
// Tints per colorant at each authored stop.
|
||||
let tints: Vec<Vec<f32>> = inks
|
||||
.iter()
|
||||
.map(|i| {
|
||||
let mut v = vec![0.0f32; process.len() + spots.len()];
|
||||
match i {
|
||||
Ink::Process(p) => {
|
||||
for (slot, j) in v.iter_mut().zip(&process) {
|
||||
*slot = p.get(*j).copied().unwrap_or(0.0);
|
||||
}
|
||||
}
|
||||
Ink::Spot { name, tint, .. } => {
|
||||
if let Some(slot) = spots.iter().position(|(n, _)| n == name).and_then(|k| v.get_mut(process.len() + k)) {
|
||||
*slot = *tint;
|
||||
}
|
||||
}
|
||||
}
|
||||
v
|
||||
})
|
||||
.collect();
|
||||
// A midpoint becomes a stop halfway between its neighbours' tints, as the display does.
|
||||
let mut expanded: Vec<(f32, Vec<f32>, f32)> = Vec::with_capacity(tints.len() * 2);
|
||||
for (k, (s, v)) in g.stops.iter().zip(&tints).enumerate() {
|
||||
expanded.push((s.offset, v.clone(), s.opacity));
|
||||
if let (Some(n), Some(nv)) = (g.stops.get(k + 1), tints.get(k + 1))
|
||||
&& (s.midpoint - 0.5).abs() > 1e-3
|
||||
{
|
||||
let mid: Vec<f32> = v.iter().zip(nv).map(|(a, b)| (a + b) / 2.0).collect();
|
||||
expanded.push((s.offset + (n.offset - s.offset) * s.midpoint, mid, (s.opacity + n.opacity) / 2.0));
|
||||
}
|
||||
}
|
||||
let mut stops = Vec::with_capacity(expanded.len());
|
||||
let mut last = 0.0f32;
|
||||
for (o, v, a) in expanded {
|
||||
let o = if o.is_finite() { o.clamp(last, 1.0) } else { last };
|
||||
last = o;
|
||||
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).
|
||||
None => {
|
||||
let mut cmyk = [0.0f32; 4];
|
||||
for (t, j) in v.iter().zip(&process) {
|
||||
if let Some(slot) = cmyk.get_mut(*j) {
|
||||
*slot = *t;
|
||||
}
|
||||
}
|
||||
let [c, m, y, k] = cmyk;
|
||||
krilla::color::cmyk::Color::new(q(c), q(m), q(y), q(k)).into()
|
||||
}
|
||||
};
|
||||
stops.push(Stop { offset: norm(o), color, opacity: norm(a) });
|
||||
}
|
||||
(!stops.is_empty()).then_some(stops)
|
||||
}
|
||||
|
||||
/// Falls back to device colours, warning when that loses a spot ink.
|
||||
fn unshaded(&mut self, swatch: &str, why: &str, has_spot: bool) -> Option<Vec<Stop>> {
|
||||
if has_spot {
|
||||
self.warn(format!("gradient '{swatch}': spot inks printed as process colour ({why})"));
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
@@ -12,8 +12,11 @@
|
||||
//! - **Colour:** CMYK as DeviceCMYK, RGB as DeviceRGB, Gray as DeviceGray, spot swatches (and their
|
||||
//! tints) as `/Separation` with the swatch's own values as the alternate space, `[Registration]`
|
||||
//! as `/Separation /All`.
|
||||
//! Gradients with mixed output colour spaces are converted to RGB with an export warning;
|
||||
//! homogeneous gradients keep their existing colour space (subject to PDF/A conversion).
|
||||
//! Gradient stops on swatches shade in their inks: one spot with tints of itself or `[Paper]`
|
||||
//! as a `/Separation` shading, a spot with process inks or a second spot as `/DeviceN`
|
||||
//! (process colorants first; not in PDF/A, which goes to process colour with a warning), and in
|
||||
//! print a process gradient to `[Paper]` as DeviceCMYK. Plain-colour stops keep their own
|
||||
//! space; gradients mixing RGB with CMYK or Gray are converted to RGB with an export warning.
|
||||
//! - **Images:** clipped to their frame; JPEG data is passed through, PNG/GIF/WebP are embedded
|
||||
//! losslessly (or re-encoded as JPEG with [`PdfOptions::compress_images`]). PDF/X-4 and PDF/A
|
||||
//! images are written without interpolation. In PDF/X-4 an RGB image without a profile of its own
|
||||
@@ -30,6 +33,7 @@
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod export;
|
||||
mod gradient_inks;
|
||||
mod links;
|
||||
mod marks;
|
||||
mod text;
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
//! Gradients on spot swatches shade in their inks: Separation for one spot, DeviceN for a spot
|
||||
//! with process inks or a second spot.
|
||||
use designcraft_color::{Color, ColorType, Gradient, GradientKind, GradientStop, Swatch, SwatchValue};
|
||||
use designcraft_compose::Cache;
|
||||
use designcraft_doc::{Document, Fill, Item, ItemId, Shape, SpreadRef, build::NewDocument};
|
||||
use designcraft_geom::{Rect, shapes};
|
||||
use designcraft_pdf::{PdfOptions, Standard, export_pdf_with_report};
|
||||
use hayro_syntax::{
|
||||
Pdf,
|
||||
object::{Array, Dict, Name},
|
||||
};
|
||||
|
||||
const CYAN: &str = "C=100 M=0 Y=0 K=0";
|
||||
|
||||
fn spot(d: &mut Document, name: &str, color: Color) {
|
||||
d.swatches.push(Swatch { value: SwatchValue::Color { color, color_type: ColorType::Spot }, ..Swatch::color(name, 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 colour, location).
|
||||
fn document(stops: &[(Result<&str, Color>, f32)], midpoint: f32) -> 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));
|
||||
d.swatches.push(Swatch { value: SwatchValue::Tint { base: "Gold".into(), tint: 0.4 }, ..Swatch::color("Gold 40%", Color::BLACK) });
|
||||
let stops = stops
|
||||
.iter()
|
||||
.map(|(s, offset)| match s {
|
||||
Ok(name) => GradientStop { swatch: Some(name.to_string()), midpoint, ..GradientStop::new(*offset, d.resolve_color(name, 1.0).unwrap()) },
|
||||
Err(c) => GradientStop { midpoint, ..GradientStop::new(*offset, *c) },
|
||||
})
|
||||
.collect();
|
||||
d.swatches.push(Swatch {
|
||||
value: SwatchValue::Gradient { gradient: Gradient { kind: GradientKind::Linear, stops } },
|
||||
..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");
|
||||
d.insert_item(SpreadRef::Doc(0), item, None).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
fn endpoint(function: &Dict<'_>, first: bool) -> Vec<f32> {
|
||||
if let Some(functions) = function.get::<Array<'_>>(b"Functions") {
|
||||
let functions: Vec<_> = functions.iter::<Dict<'_>>().collect();
|
||||
return endpoint(if first { functions.first().unwrap() } else { functions.last().unwrap() }, first);
|
||||
}
|
||||
function.get::<Array<'_>>(if first { b"C0" } else { b"C1" }).unwrap().iter::<f32>().collect()
|
||||
}
|
||||
|
||||
/// Each shading on the page: its color space family, colorant names, and the colours at its
|
||||
/// two ends.
|
||||
struct Shading {
|
||||
family: String,
|
||||
colorants: Vec<String>,
|
||||
start: Vec<f32>,
|
||||
end: Vec<f32>,
|
||||
}
|
||||
|
||||
fn export(d: &Document, standard: Standard) -> (Vec<Shading>, Vec<String>) {
|
||||
let report = export_pdf_with_report(d, &Cache::new(), &PdfOptions { standard, compress: false, ..Default::default() }).unwrap();
|
||||
let pdf = Pdf::new(report.bytes).unwrap();
|
||||
let patterns = &pdf.pages()[0].resources().patterns;
|
||||
let mut out = Vec::new();
|
||||
for (name, _) in patterns.entries() {
|
||||
let pattern = patterns.get::<Dict<'_>>(name.as_ref()).unwrap();
|
||||
let shading = pattern.get::<Dict<'_>>(b"Shading").unwrap();
|
||||
let text = |n: Name<'_>| String::from_utf8_lossy(n.as_ref()).into_owned();
|
||||
let (family, colorants) = match shading.get::<Name<'_>>(b"ColorSpace") {
|
||||
Some(n) => (text(n), Vec::new()),
|
||||
None => {
|
||||
let space = shading.get::<Array<'_>>(b"ColorSpace").unwrap();
|
||||
let mut it = space.flex_iter();
|
||||
let family = text(it.next::<Name<'_>>().unwrap());
|
||||
let colorants = match family.as_str() {
|
||||
"Separation" => vec![text(it.next::<Name<'_>>().unwrap())],
|
||||
"DeviceN" => it.next::<Array<'_>>().unwrap().iter::<Name<'_>>().map(text).collect(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
(family, colorants)
|
||||
}
|
||||
};
|
||||
let function = shading.get::<Dict<'_>>(b"Function").unwrap();
|
||||
out.push(Shading { family, colorants, start: endpoint(&function, true), end: endpoint(&function, false) });
|
||||
}
|
||||
assert!(!out.is_empty(), "the gradient is a shading");
|
||||
(out, report.warnings)
|
||||
}
|
||||
|
||||
fn close(a: &[f32], b: &[f32]) -> bool {
|
||||
a.len() == b.len() && a.iter().zip(b).all(|(a, b)| (a - b).abs() < 0.01)
|
||||
}
|
||||
|
||||
fn no_conversion(warnings: &[String]) {
|
||||
assert!(!warnings.iter().any(|w| w.contains("Fade")), "{warnings:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_spot_to_paper_gradient_is_a_separation_shading() {
|
||||
for standard in [Standard::None, Standard::PdfX4] {
|
||||
let (shadings, warnings) = export(&document(&[(Ok("Gold"), 0.0), (Ok("[Paper]"), 1.0)], 0.5), standard);
|
||||
no_conversion(&warnings);
|
||||
let s = &shadings[0];
|
||||
assert_eq!((s.family.as_str(), s.colorants.as_slice()), ("Separation", &["Gold".to_string()][..]), "{standard:?}");
|
||||
assert!(close(&s.start, &[1.0]) && close(&s.end, &[0.0]), "{:?} → {:?}", s.start, s.end);
|
||||
}
|
||||
// A tint of the spot shades in the same Separation.
|
||||
let (shadings, _) = export(&document(&[(Ok("Gold"), 0.0), (Ok("Gold 40%"), 1.0)], 0.5), Standard::PdfX4);
|
||||
assert_eq!(shadings[0].family, "Separation");
|
||||
assert!(close(&shadings[0].end, &[0.4]), "{:?}", shadings[0].end);
|
||||
// Converted to process in the Ink Manager: CMYK.
|
||||
let mut d = document(&[(Ok("Gold"), 0.0), (Ok("[Paper]"), 1.0)], 0.5);
|
||||
d.inks.to_process.push("Gold".into());
|
||||
let (shadings, _) = export(&d, Standard::PdfX4);
|
||||
assert_eq!(shadings[0].family, "DeviceCMYK");
|
||||
assert!(close(&shadings[0].start, &[0.0, 0.3, 0.9, 0.1]), "{:?}", shadings[0].start);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_spot_to_cyan_gradient_is_a_device_n_shading() {
|
||||
let (shadings, warnings) = export(&document(&[(Ok("Gold"), 0.0), (Ok(CYAN), 1.0)], 0.5), Standard::PdfX4);
|
||||
no_conversion(&warnings);
|
||||
let s = &shadings[0];
|
||||
assert_eq!(s.family, "DeviceN");
|
||||
assert_eq!(s.colorants, ["Cyan", "Gold"]);
|
||||
assert!(close(&s.start, &[0.0, 1.0]) && close(&s.end, &[1.0, 0.0]), "{:?} → {:?}", s.start, s.end);
|
||||
// A midpoint stays in the inks: the colours at the ends are unchanged and in DeviceN.
|
||||
let (shadings, _) = export(&document(&[(Ok("Gold"), 0.0), (Ok(CYAN), 1.0)], 0.25), Standard::PdfX4);
|
||||
assert_eq!(shadings[0].family, "DeviceN");
|
||||
assert!(close(&shadings[0].start, &[0.0, 1.0]) && close(&shadings[0].end, &[1.0, 0.0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_two_spot_gradient_is_a_device_n_shading() {
|
||||
let (shadings, warnings) = export(&document(&[(Ok("Gold"), 0.0), (Ok("Silver"), 1.0)], 0.5), Standard::PdfX4);
|
||||
no_conversion(&warnings);
|
||||
let s = &shadings[0];
|
||||
assert_eq!(s.family, "DeviceN");
|
||||
assert_eq!(s.colorants, ["Gold", "Silver"]);
|
||||
assert!(close(&s.start, &[1.0, 0.0]) && close(&s.end, &[0.0, 1.0]), "{:?} → {:?}", s.start, s.end);
|
||||
// Stop opacity is a soft mask over the same inks.
|
||||
let mut d = document(&[(Ok("Gold"), 0.0), (Ok("Silver"), 1.0)], 0.5);
|
||||
if let Some(SwatchValue::Gradient { gradient }) = d.swatches.iter_mut().find(|w| w.name == "Fade").map(|w| &mut w.value) {
|
||||
gradient.stops[0].opacity = 0.4;
|
||||
}
|
||||
let (shadings, warnings) = export(&d, Standard::PdfX4);
|
||||
no_conversion(&warnings);
|
||||
assert!(shadings.iter().any(|s| s.family == "DeviceN" && s.colorants == ["Gold", "Silver"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_process_gradient_to_paper_prints_in_cmyk() {
|
||||
let (shadings, warnings) = export(&document(&[(Ok(CYAN), 0.0), (Ok("[Paper]"), 1.0)], 0.5), Standard::PdfX4);
|
||||
no_conversion(&warnings);
|
||||
assert_eq!(shadings[0].family, "DeviceCMYK");
|
||||
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]));
|
||||
}
|
||||
|
||||
/// PDF/A forbids DeviceN's PostScript tint transform: the gradient goes to process colour, with
|
||||
/// a warning, and the export still succeeds.
|
||||
#[test]
|
||||
fn pdf_a_writes_mixed_spot_gradients_in_process_colour() {
|
||||
let (shadings, warnings) = export(&document(&[(Ok("Gold"), 0.0), (Ok(CYAN), 1.0)], 0.5), Standard::PdfA2b);
|
||||
assert!(warnings.iter().any(|w| w.contains("'Fade'") && w.contains("spot inks printed as process")), "{warnings:?}");
|
||||
assert!(shadings.iter().all(|s| s.family != "DeviceN"));
|
||||
}
|
||||
|
||||
/// Stops with plain colours (every document saved before stops named swatches) export as they
|
||||
/// did: the spot's values in DeviceCMYK.
|
||||
#[test]
|
||||
fn plain_colour_stops_keep_their_device_space() {
|
||||
let gold = Color::cmyk(0.0, 0.3, 0.9, 0.1);
|
||||
let (shadings, warnings) = export(&document(&[(Err(gold), 0.0), (Err(Color::cmyk(1.0, 0.0, 0.0, 0.0)), 1.0)], 0.5), Standard::PdfX4);
|
||||
no_conversion(&warnings);
|
||||
assert_eq!(shadings[0].family, "DeviceCMYK");
|
||||
assert!(close(&shadings[0].start, &[0.0, 0.3, 0.9, 0.1]));
|
||||
}
|
||||
Reference in New Issue
Block a user