PDF export: a gradient that mixes a spot color with process colors keeps the spot ink

A linear or radial gradient whose stops mix a spot color with process
colors, or two spot colors, was written in process colors with a
warning. It is now written as a DeviceN shading of the process inks
its stops use and its spot inks, so each spot ink separates onto its
own plate. An off-center midpoint becomes a stop with the inks halfway
between its two stops. A gradient that mixes a spot color with RGB or
Lab colors is still written without the spot ink, with a warning that
says so.
This commit is contained in:
dotCooCoo
2026-11-01 04:55:11 +07:00
committed by bflatastic
parent 3f5c6f3b5b
commit ba16468710
3 changed files with 102 additions and 8 deletions
+29 -3
View File
@@ -1,5 +1,5 @@
//! Gradient swatches and gradient stops linked to global and spot swatches: the recorded links,
//! stops following swatch edits, spot stops on their plate and as Separation shadings in PDF.
//! stops following swatch edits, spot stops on their plate and as Separation and DeviceN shadings in PDF.
use serde_json::{Value, json};
use vectorcraft_color::{Color, GradientPaint, GradientStop, Paint};
@@ -133,13 +133,39 @@ fn spot_gradients_export_as_separation_shadings() {
assert!(pdf.contains("/ShadingType 2") && pdf.contains("/Separation/Ink/DeviceCMYK"), "an Ink → paper gradient is a Separation shading");
// Its function runs over the tints: 80 % (204 / 255) to 0 %.
assert!(pdf.contains("/C0[0.8]/C1[0]"), "the shading's tints");
// Mixing the spot ink with another colour falls back to process colours, with a warning.
// Mixing the spot ink with an RGB color writes the gradient without the spot ink, with a warning.
run(&mut s, "paint.editGradient", json!({"stops": [{"offset": 0, "swatch": "Ink"}, {"offset": 1, "color": "#00ff00"}]}));
let (pdf, warnings) = pdf_of(&s);
assert!(!pdf.contains("/Separation"), "no Separation for a mixed gradient");
assert!(!pdf.contains("/Separation") && !pdf.contains("/DeviceN"), "no spot ink for a gradient with an RGB stop");
assert!(warnings.iter().any(|w| w.contains("spot")), "{warnings:?}");
}
#[test]
fn spot_gradients_mixed_with_other_inks_export_as_device_n_shadings() {
let (mut s, _) = setup();
// Ink → process yellow: a DeviceN shading of Yellow and Ink, with no warning.
run(&mut s, "paint.editGradient", json!({"stops": [{"offset": 0, "swatch": "Ink"}, {"offset": 1, "color": {"c": 0, "m": 0, "y": 0.6, "k": 0}}]}));
let (pdf, warnings) = pdf_of(&s);
assert!(pdf.contains("/ShadingType 2") && pdf.contains("/DeviceN[/Yellow/Ink]/DeviceCMYK"), "a DeviceN shading of Yellow and Ink");
assert!(pdf.contains("/C0[0 1]/C1[0.6 0]"), "the shading's tints: Yellow and Ink");
assert!(!warnings.iter().any(|w| w.contains("spot")), "{warnings:?}");
// Two spot inks, with an off-center midpoint: a stop halfway between the two inks.
run(&mut s, "swatch.new", json!({"name": "Leaf", "color": {"c": 1, "m": 0, "y": 1, "k": 0}, "spot": true}));
run(
&mut s,
"paint.editGradient",
json!({"stops": [{"offset": 0, "swatch": "Ink", "midpoint": 30}, {"offset": 1, "swatch": "Leaf", "tint": 50}]}),
);
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!(!warnings.iter().any(|w| w.contains("spot")), "{warnings:?}");
}
#[test]
fn linked_stops_round_trip() {
let (mut s, id) = setup();
+71 -4
View File
@@ -65,8 +65,8 @@ impl Exporter<'_> {
/// The stops of a gradient whose every stop is a tint of one spot ink (or paper white, 0% of
/// it) as tints of that ink — a Separation shading — with midpoints as explicit stops (the
/// tint and opacity there are halfway). `None` for other gradients; one that mixes a spot ink
/// with other colours is written in process colours (the PDF writer has no DeviceN).
/// tint and opacity there are halfway). A gradient that mixes a spot ink with other colors
/// gets [`Self::device_n_stops`]. `None` for gradients without a spot ink.
fn spot_stops(&mut self, g: &vectorcraft_color::Gradient) -> Option<Vec<(f32, krilla::color::Color, f32)>> {
let ink = g.stops.iter().find_map(|s| s.swatch.as_deref().filter(|n| self.doc.swatch(n).is_some_and(|w| w.spot)))?;
let paper = |c: &Color| c.to_rgba8(1.0)[..3] == [255; 3];
@@ -80,8 +80,7 @@ impl Exporter<'_> {
})
.collect();
let Some(tints) = tints else {
self.warn("gradients mixing a spot color with other colors are exported in process colors");
return None;
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() };
@@ -98,6 +97,74 @@ impl Exporter<'_> {
Some(out)
}
/// The stops of a gradient that mixes spot inks with process colors or with each other, in
/// a DeviceN space of the process inks the stops use (in CMYK order) and the spot inks (in
/// the order the stops name them), with midpoints as explicit stops (the tints and opacity
/// there are halfway). Paper white adds no ink. `None`, with a warning, when a stop is
/// written in RGB or Lab or the spot inks can't share a DeviceN space; the gradient is then
/// written in the colors of its stops.
fn device_n_stops(&mut self, g: &vectorcraft_color::Gradient) -> Option<Vec<(f32, krilla::color::Color, f32)>> {
use krilla::color::device_n::{Color as DnColor, Colorant, DeviceNSpace, Process};
let paper = |c: &Color| c.to_rgba8(1.0)[..3] == [255; 3];
let doc = self.doc;
fn spot_of<'s>(doc: &vectorcraft_doc::Document, s: &'s vectorcraft_color::GradientStop) -> Option<&'s str> {
s.swatch.as_deref().filter(|n| doc.swatch(n).is_some_and(|w| w.spot))
}
let mut spots: Vec<(String, [f32; 4])> = Vec::new();
for s in &g.stops {
if let Some(n) = spot_of(doc, s)
&& !spots.iter().any(|(m, _)| m == n)
{
spots.push(self.spot_ink(n)?);
}
}
// Each stop's ink coverage: the four process inks, then one tint per spot ink.
let mut inks: Vec<Vec<f32>> = Vec::with_capacity(g.stops.len());
for s in &g.stops {
let mut v = vec![0.0; 4 + spots.len()];
match spot_of(doc, s) {
Some(n) => {
let i = spots.iter().position(|(m, _)| m == n)?;
v[4 + i] = s.tint;
}
None if s.swatch.is_none() && paper(&s.color) => {}
None => match self.page_color(&s.color) {
Color::Cmyk { c, m, y, k } => v[..4].copy_from_slice(&[c, m, y, k]),
Color::Gray { k } => v[3] = k,
Color::Rgb { .. } | Color::Lab { .. } => {
self.warn("gradients mixing a spot color with RGB or Lab colors are exported without the spot ink");
return None;
}
},
}
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 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 mut out = vec![];
for (i, s) in g.stops.iter().enumerate() {
out.push((s.offset, color(&inks[i])?, s.opacity));
if let Some(n) = g.stops.get(i + 1)
&& (s.midpoint - 0.5).abs() > 1e-3
{
let at = s.offset + (n.offset - s.offset) * s.midpoint;
let half: Vec<f32> = inks[i].iter().zip(&inks[i + 1]).map(|(a, b)| (a + b) / 2.0).collect();
out.push((at, color(&half)?, (s.opacity + n.opacity) / 2.0));
}
}
Some(out)
}
/// Warn when `effects` (an object's, a fill's or a stroke's) has a visible raster effect: the
/// app renders them to images before export (`vectorcraft_engine::export_pdf`), so the ones
/// still here (on `what`) can't be written.
+2 -1
View File
@@ -393,7 +393,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale)
| Selection, transform & align tools | 6 | 85% | Direct Selection segment click/Delete corrected (#1278); remaining: Free Transform/Scale/Reflect along a rotated box, Global Edit of similar groups, transform nuances (the rotated persistent bounding box is done) | 4–6 |
| Drawing tools | 7 | 86% | rough-square Shaper recognition and orientation steps corrected; editable Shaper Groups landed; Pencil: Alt key toggles to Smooth Tool (#2001); remaining: Shaper construction widget/gesture refinements, pencil and the remaining pen modifier nuances, Blob Brush Merge Only with Selection, Touch Type, Live Corners' Relative rounding | 7–12 |
| Path operations, Pathfinder, Shape Builder, Live Paint | 5 | 86% | Live Paint gap options, Shape Builder gap detection, options (Tool Options dialog, clicking a stroke in merge mode) and Shift-drag marquee | 3–6 |
| Colour, swatches, gradients, patterns, mesh, recolor | 7 | 96% | freeform and mixed spot/process gradients in SVG/PDF (exported as stops or process colours), pattern fills in Expand | 2–4; Eyedropper source reads regression-tested (#1251) |
| Colour, swatches, gradients, patterns, mesh, recolor | 7 | 96% | freeform gradients in SVG/PDF and mixed spot/process gradients in SVG (exported as stops; PDF export keeps the spot inks in a DeviceN shading), pattern fills in Expand | 2–4; Eyedropper source reads regression-tested (#1251) |
| Strokes, brushes, width profiles | 5 | 90% | the Art brush's Overlap, auto-generated Pattern brush corners and new tile art in Pattern Brush Options (Brush Options for every brush type done), more built-in brush libraries (four generated in code are done), pen pressure on macOS (the windowing layer reports none there), brushes driven by pen tilt, bearing and barrel rotation (6D art pens) | 8–15 |
| Appearance, transparency, graphic styles, masks | 5 | 98% | raster effect reach in preview bounds (flattener presets apply in PDF, EPS and print) | 1–2 |
| Live vector effects | 5 | 85% | Outline Object, Pathfinder Hard/Soft Mix and Trap (since done: #3061, #3063, #3785), SVG Filters, Transform Patterns and Scale Strokes & Effects on a fill's or stroke's own Transform effect | 6–10 |
@@ -1027,6 +1027,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- PDF export and print composition use the optional shared guarded writer ([pdf-backend.md](pdf-backend.md)); app drawing and output policy and desktop printer submission remain in their existing layers. The shared reader keeps the app-owned import adapter; synthetic tests cover layer and overprint state across windows, long operands, inline images, failed decoding, document limits and incomplete tails. Empty and populated metadata key presence is regression-tested, and CI checks the immutable backend source.
Invisible text in a PDF (rendering mode 3, which carries a scanned page's recognized text over the scan) opens as type with no fill or stroke, a run of its own beside visible text on the same line; text that can't come in as type (opened as outlines, Type 3 fonts) is left out with a warning. PDF export, and printing, write characters with no fill or stroke as real text in rendering mode 3, so they draw nothing but stay selectable and searchable, also in PDF/X-1a and PDF/A-2b (#2675, `pdf/src/tests/import_fidelity.rs`, `pdf/src/tests/realtext.rs`).
A PDF, or a `.ai` read through its PDF part, whose spot inks have the same values but different names opens with a spot swatch for each ink. The solid fills and strokes of paths and type, and the stops of gradients on paths, link to the swatch of the ink they are painted in, the points of a gradient mesh in one of these inks keep the ink's color, and PDF export writes each of these inks as its own separation (`pdf/src/tests/import_color.rs`). Paint in these inks inside pattern cells, soft masks and type 3 glyphs, and gradients on type in these inks, still import as RGB. Type in one of these inks can also import as RGB when type in another Separation or DeviceN ink is shown with no path drawn between them.
PDF export writes a linear or radial gradient that mixes a spot color with process colors, or two spot colors, as a DeviceN shading of the spot inks and the process inks its stops use, so each spot ink prints on its own plate; an off-center midpoint is written as a stop with the inks halfway between its two stops. A gradient that mixes a spot color with RGB or Lab colors is still written without the spot ink, with a warning (`engine/src/tests/linked_stops.rs`).
- `.vectorcraft` (lossless JSON, compressed, atomic saves, save down to v1), Save As with a format chooser, Save a Copy, Revert, templates, Data Recovery after a crash (browser tabs hold their copies with Web Locks too, so a background tab's copies are never taken for a crash's, and a tab repairs copies another removed when it resumes: #367), background save and export. The desktop app writes its warnings and errors (graphics adapter, a lost graphics device, panics the guard recovered from) to standard error and to `logs/vectorcraft.log` next to the preferences, the two previous runs kept and each file capped at 16 MiB, so a launch without a terminal still leaves something to attach to a bug report (#465). Native documents with a lot of path data (traced or generated art, in layers, symbols or patterns) are saved as compact JSON, about a fifth of the size; small documents stay pretty-printed (#1492). Saving to a network drive on macOS falls back to a plain fsync when the share refuses a full flush (#1625, part of #1620; not confirmed on a real share), and a save still succeeds when a folder refuses the temporary file, the rename over the document or copying permissions: the content is written elsewhere first and copied in, and a failed copy keeps the temporary file and names it (#1649, part of #1620). A document opened by a relative path finds its relatively linked images, and `vectorcraft-cli convert` lists what reading the input left out as `importWarnings` (#1606, fixes #1604).
A name typed in the desktop save panel without the file type's extension gets it, and when a file of that name exists, which the panel did not ask about, Save, Save As, Save a Copy, Save as Template, the exports and every other write through a save panel ask before replacing it (dialog `replaceFile`; Cancel writes nothing).
A name returned by the desktop save panel with the extension of a format the save does not write has it replaced with the saved format's: Save As of a new document saves `card.ait` and `card.png` as `card.vectorcraft`, and Save as Template saves `card.pdf` as `card.vctemplate`. An extension that names no format stays part of the name (`notes.v2` is saved as `notes.v2.vectorcraft`).