mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-14 19:27:07 -04:00
fix(color): gradients between process colours blend as inks, as InDesign shows them
LYH Studio episode 6 builds every gradient from C=0 M=100 Y=0 K=0 to C=100 M=0 Y=0 K=0. InDesign blends a gradient's process stops as inks: halfway is C 50 M 50, a light lilac. DesignCraft converted both ends to RGB and blended there, so the middle came out a darker violet (117, 88, 189 instead of 135, 119, 181), and every Episode 6 gradient looked different. A midpoint between CMYK stops also became an RGB colour, so the PDF shading turned RGB with a conversion warning. - color: `Color::lerp` blends CMYK with CMYK (or grey), and grey with grey, in ink space; anything with RGB still blends in RGB. - `Gradient::display_stops`: the expanded stops plus samples through each span that isn't RGB at both ends, for renderers that interpolate in RGB; the screen renderer uses it. - pdf: a midpoint's expanded stop now keeps the stops' space; the colour-space test expects no conversion for CMYK or grey midpoints. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
e862e0a613
commit
dc712baeca
7 files changed
+92
-5
No files matched your search
@@ -129,6 +129,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-12** · macOS Dock Quit uses the same unsaved-document prompts as window and menu Quit. Repeated requests keep the current answers; Cancel retains every tab and unsaved recovery checkpoint, and completed Save/Don't Save answers exit through the ordinary event loop. No readiness percentage change.
|
||||
- **2026-10-12** · Orderly native exit writes the latest recovery state after finalizing current quit approvals; incomplete or stale plans keep their documents, and Undo to the saved state clears stale recovery. Unavailable or failed recovery is reported. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change.
|
||||
- **2026-10-12** · Recovered documents keep a checkpoint until saved, closed or reverted, including a second crash before autosave. Restarted document IDs and explicit recovery folders preserve other snapshots; failed republishing retains the original. No readiness percentage change.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -170,7 +170,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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -880,7 +880,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, angle) = (fill.swatch.as_str(), fill.tint, fill.gradient_angle);
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
@@ -436,7 +436,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.
|
||||
|
||||
Reference in new issue
Block a user