Merge #836: gradients between process colours blend as inks

# Conflicts:
#	ROADMAP.md
This commit is contained in:
aiongg
2026-10-13 22:31:16 +00:00
7 changed files with 92 additions and 5 deletions
+1
View File
@@ -128,6 +128,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m
Newest first.
- **2026-10-13** · Gradients between process colours blend as inks, as InDesign shows them: magenta to cyan passes through C 50 M 50 (a light lilac) on screen instead of the darker RGB mix, and a midpoint between CMYK or grey stops no longer turns the PDF shading RGB. No readiness percentage change.
- **2026-10-13** · Commands refuse parameter keys their params description doesn't name (#776): the control channel, MCP (`-32602`, every `batch` step checked before the first runs) and `designcraft-cli --cmd` answer with an “unknown parameter” error naming the nearest or the accepted keys instead of running with defaults; journals still replay. Descriptions of the table, anchored-object, data-merge and placing commands now name every key they read. No readiness percentage change.
- **2026-10-13** · File ▸ Open Recent lists the documents last opened or saved (newest first, no duplicates; Save a Copy and exports don't count), with Clear Recent Files; the start screen shows the last eight, below the community card, as cards with a picture of the first page: saving puts one in the file (`preview.png`), so the start screen reads it without opening the document. A file that is gone is dimmed and says so when chosen, and stays listed. Preferences ▸ File Handling sets how many are kept (default 20, 0–100). `file.openRecent {index}`, `file.recentFiles`, `file.clearRecent` (#706). No readiness percentage change.
- **2026-10-13** · Dropping an `.ase` file on the window loads its swatches, as Load Swatches does (the drop failed with "Can't Place the File"). No readiness percentage change.
+39
View File
@@ -103,6 +103,26 @@ impl Gradient {
}
out
}
/// Stops for a renderer that blends between them in RGB: the expanded stops, plus samples
/// through each span that isn't RGB at both ends, so process colours keep blending as inks.
pub fn display_stops(&self) -> Vec<(f32, Color, f32)> {
const STEPS: usize = 16;
let stops = self.expanded_stops();
let mut out: Vec<(f32, Color, f32)> = Vec::with_capacity(stops.len() * STEPS);
for (i, s) in stops.iter().enumerate() {
out.push(*s);
let Some(n) = stops.get(i + 1) else { continue };
if matches!((s.1, n.1), (Color::Rgb { .. }, Color::Rgb { .. })) || n.0 - s.0 <= 1e-4 {
continue;
}
for j in 1..STEPS {
let t = s.0 + (n.0 - s.0) * j as f32 / STEPS as f32;
let (c, a) = self.sample(t);
out.push((t, c, a));
}
}
out
}
pub fn reverse(&mut self) {
// A midpoint belongs to the segment after its stop, so it moves with that segment and mirrors.
let mids: Vec<_> = self.stops.iter().rev().skip(1).map(|s| 1.0 - s.midpoint).collect();
@@ -199,6 +219,25 @@ mod tests {
assert!((mid - 0.5).abs() < 1e-5);
}
#[test]
fn process_stops_blend_as_inks() {
// LYH Studio episode 6: magenta to cyan is C 50 M 50 halfway (a light lilac), not the RGB mix.
let mut g = Gradient::default();
g.stops[0].color = Color::cmyk(0.0, 1.0, 0.0, 0.0);
g.stops[1].color = Color::cmyk(1.0, 0.0, 0.0, 0.0);
assert_eq!(g.sample(0.5).0, Color::cmyk(0.5, 0.5, 0.0, 0.0));
let d = g.display_stops();
assert_eq!(d.len(), 17, "sampled through the span");
assert!(d.iter().any(|(o, c, _)| (*o - 0.5).abs() < 1e-6 && *c == Color::cmyk(0.5, 0.5, 0.0, 0.0)));
// A midpoint stays an ink too; RGB stops aren't sampled.
g.stops[0].midpoint = 0.25;
assert!(matches!(g.expanded_stops()[1].1, Color::Cmyk { .. }));
assert_eq!(Gradient::default().display_stops().len(), 2);
// Grey and CMYK meet as inks; RGB with anything else blends in RGB.
assert_eq!(Color::gray(1.0).lerp(&Color::cmyk(1.0, 0.0, 0.0, 0.0), 0.5), Color::cmyk(0.5, 0.0, 0.0, 0.5));
assert!(matches!(Color::rgb(1.0, 0.0, 0.0).lerp(&Color::cmyk(1.0, 0.0, 0.0, 0.0), 0.5), Color::Rgb { .. }));
}
#[test]
fn midpoint_shifts() {
let mut g = Gradient::default();
+10
View File
@@ -174,7 +174,17 @@ impl Color {
Color::rgb(1.0 - r, 1.0 - g, 1.0 - b)
}
/// Linear interpolation in display RGB.
/// The colour `t` of the way to `other`. Process inks blend as inks (CMYK with CMYK or grey,
/// grey with grey), as InDesign blends a gradient's stops; anything else blends in RGB.
pub fn lerp(&self, other: &Color, t: f32) -> Color {
let l = |a: f32, b: f32| a + (b - a) * t;
match (*self, *other) {
(Color::Cmyk { c, m, y, k }, Color::Cmyk { c: c2, m: m2, y: y2, k: k2 }) => return Color::cmyk(l(c, c2), l(m, m2), l(y, y2), l(k, k2)),
(Color::Gray { k }, Color::Gray { k: k2 }) => return Color::gray(l(k, k2)),
(Color::Gray { k }, Color::Cmyk { .. }) => return Color::cmyk(0.0, 0.0, 0.0, k).lerp(other, t),
(Color::Cmyk { .. }, Color::Gray { k }) => return self.lerp(&Color::cmyk(0.0, 0.0, 0.0, k), t),
_ => {}
}
let a = self.to_rgb();
let b = other.to_rgb();
Color::rgb(a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t)
+2 -3
View File
@@ -78,15 +78,14 @@ fn mixed_gradient_export_survives_in_subprocess() {
}
}
}
// Expansion inserts an RGB midpoint even when the authoring stops share CMYK or Gray.
// A midpoint's expanded stop blends in the stops' own space: CMYK or Gray stays homogeneous.
for colors in [[cmyk, Color::cmyk(0.0, 1.0, 0.0, 0.0)], [gray, Color::gray(0.8)]] {
for kind in [GradientKind::Linear, GradientKind::Radial] {
for standard in [Standard::None, Standard::PdfX4, Standard::PdfA2b] {
let report =
export_pdf_with_report(&document(&colors, kind, 0.25, 1.0), &Cache::new(), &PdfOptions { standard, ..Default::default() })
.unwrap();
let archival_cmyk = standard == Standard::PdfA2b && matches!(colors[0], Color::Cmyk { .. });
assert_eq!(conversion_warnings(&report.warnings), usize::from(!archival_cmyk), "{standard:?}: {:?}", report.warnings);
assert_eq!(conversion_warnings(&report.warnings), 0, "{standard:?}: {:?}", report.warnings);
assert_eq!(Pdf::new(report.bytes).unwrap().pages().len(), 1);
}
}
+3 -1
View File
@@ -1059,7 +1059,9 @@ pub fn color_of(c: &designcraft_color::Color, alpha: f32) -> peniko::Color {
fn set_fill_paint(ctx: &mut RenderContext, doc: &Document, fill: &designcraft_doc::Fill, bounds: Rect) -> bool {
let (swatch, tint) = (fill.swatch.as_str(), fill.tint);
if let Some(g) = designcraft_color::swatch::resolve_gradient(&doc.swatches, swatch) {
let stops: Vec<peniko::ColorStop> = g.expanded_stops().iter().map(|(o, c, a)| peniko::ColorStop::from((*o, color_of(c, *a)))).collect();
// Sampled so process colours blend as inks, as InDesign shows them (and as the PDF's CMYK
// shading prints).
let stops: Vec<peniko::ColorStop> = g.display_stops().iter().map(|(o, c, a)| peniko::ColorStop::from((*o, color_of(c, *a)))).collect();
if stops.is_empty() {
return false;
}
+36
View File
@@ -0,0 +1,36 @@
//! A gradient between process colours shows the inks' blend, as InDesign shows it.
use designcraft_color::swatch::Swatch;
use designcraft_color::{Color, Gradient, GradientKind, GradientStop, SwatchValue};
use designcraft_compose::Cache;
use designcraft_doc::build::NewDocument;
use designcraft_doc::{Document, Fill, Item, ItemId, Shape, SpreadRef, Stroke};
use designcraft_geom::{Affine, Rect, shapes};
use designcraft_render::{Placed, RenderOptions, Renderer};
#[test]
fn a_cmyk_gradient_is_c50_m50_halfway() {
let mut d = Document::new(&NewDocument { width: 100.0, height: 20.0, facing_pages: false, ..Default::default() });
let stop = |offset: f32, color: Color| GradientStop { offset, color, opacity: 1.0, midpoint: 0.5 };
let gradient =
Gradient { kind: GradientKind::Linear, stops: vec![stop(0.0, Color::cmyk(0.0, 1.0, 0.0, 0.0)), stop(1.0, Color::cmyk(1.0, 0.0, 0.0, 0.0))] };
d.swatches.push(Swatch { name: "Magenta to Cyan".into(), value: SwatchValue::Gradient { gradient }, locked: false, named: true, hidden: false });
let id = ItemId(d.alloc());
let mut item = Item::new(id, d.default_layer(), Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 20.0)));
item.fill = Fill::swatch("Magenta to Cyan");
item.stroke = Stroke::default();
d.insert_item(SpreadRef::Doc(0), item, None).unwrap();
let img = Renderer::new().render(
&d,
&Cache::new(),
&[Placed { spread: SpreadRef::Doc(0), xf: Affine::IDENTITY }],
100,
20,
Affine::IDENTITY,
&RenderOptions { paper: false, background: Some([255; 4]), ..Default::default() },
);
let want = Color::cmyk(0.5, 0.5, 0.0, 0.0).to_rgba8(1.0);
let got = img.pixel(50, 10);
for ch in 0..3 {
assert!(got[ch].abs_diff(want[ch]) <= 4, "halfway {got:?}, the inks' blend is {want:?}");
}
}
+1 -1
View File
@@ -450,7 +450,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level
Task notes that headed ROADMAP.md:
- FID1: PDF export normalizes mixed emitted gradient color spaces to RGB with an explicit warning; homogeneous device-space gradients retain their components. Linear/radial, midpoint, archival and print policies have synthetic regression coverage.
- FID1: PDF export normalizes mixed emitted gradient color spaces to RGB with an explicit warning; homogeneous device-space gradients retain their components. Process stops (CMYK, grey) blend as inks on screen and at midpoints, so a midpoint keeps the shading CMYK. Linear/radial, midpoint, archival and print policies have synthetic regression coverage.
- AR1.1: Kashida justification tests verify space fallback without Tatweel fonts using an isolated font database, while retaining elongation assertions when fonts support it.
- AR1: Arabic IDML controls now retain character direction, kashida switches, diacritic offsets/forms, paragraph policy values and independent story/table directions. Shaping preserves joining context and script/language selection; paragraph-level bidi, contextual digit conversion, mark-safe elongation, RTL columns/tables and physical column insertion have regression coverage. Vendor-specific justification and diacritic presets remain explicit preflight warnings; see docs/arabic-typography.md.
- CJK1: IDML composite fonts now retain character mappings and metrics; explicit aki, tsume, jidori, em alignment/leading, named/custom kinsoku boundaries, hanging punctuation, tate-chu-yoko offsets and kenten symbols flow through composition and IDML export. Automatic kerning no longer imports inactive numeric values as enormous manual spacing. Composite definitions are editable through commands with undo/cache invalidation. Mojikumi definitions are retained and unsupported spacing-table / push-in-push-out policies are reported by preflight. Full InDesign CJK composition parity, frame-grid layout and detailed ruby remain unfinished. Warichu sets the selected run in smaller lines inside the line after the line is broken.