This commit is contained in:
Brandon Thomas committed 2026-10-14 12:39:58 -04:00
commit e8a3cda237
8 files changed
+104 -8

No files matched your search

+1
View File
@@ -132,6 +132,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m
Newest first.
- **2026-10-13** · Object › Fitting has InDesign's shortcuts: Fill Frame Proportionally ⌘⌥⇧C, Fit Content Proportionally ⌘⌥⇧E, Fit Frame to Content ⌘⌥C, Fit Content to Frame ⌘⌥E, Center Content ⌘⇧E. No readiness percentage change.
- **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** · Text threads by hand: click a selected text frame's in or out port to load
the text cursor, then click a frame to thread it, click empty space for a column-wide frame or
drag one (it snaps like frame drawing); Shift-click autoflows, Alt-click stays loaded on the
+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 -1
View File
@@ -169,8 +169,17 @@ impl Color {
let [r, g, b] = self.to_rgb();
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)
+9 -1
View File
@@ -981,7 +981,15 @@ impl Exporter<'_> {
let (swatch, tint, angle) = (fill.swatch.as_str(), fill.tint, fill.gradient_angle);
if let Some(g) = designcraft_color::swatch::resolve_gradient(&self.doc.swatches, swatch) {
let kind = g.kind;
let expanded = g.expanded_stops();
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 {
+5 -4
View File
@@ -53,7 +53,7 @@ fn mixed_gradient_export_survives_in_subprocess() {
let rgb = Color::rgb(1.0, 0.0, 0.0);
let cmyk = Color::cmyk(1.0, 0.0, 0.0, 0.0);
let gray = Color::gray(0.4);
for colors in [vec![cmyk, rgb], vec![rgb, cmyk], vec![rgb, gray], vec![gray, cmyk], vec![gray, rgb, cmyk]] {
for colors in [vec![cmyk, rgb], vec![rgb, cmyk], vec![rgb, gray], vec![gray, rgb, cmyk]] {
for kind in [GradientKind::Linear, GradientKind::Radial] {
for standard in [Standard::None, Standard::PdfX4, Standard::PdfA2b] {
let d = document(&colors, kind, 0.5, 0.7);
@@ -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);
}
}
@@ -108,6 +107,8 @@ fn homogeneous_gradients_keep_device_spaces_and_components() {
([Color::rgb(1.0, 0.0, 0.0), Color::rgb(0.0, 0.0, 1.0)], "DeviceRGB", vec![1.0, 0.0, 0.0], vec![0.0, 0.0, 1.0]),
([Color::cmyk(1.0, 0.0, 0.0, 0.0), Color::cmyk(0.0, 1.0, 0.0, 0.0)], "DeviceCMYK", vec![1.0, 0.0, 0.0, 0.0], vec![0.0, 1.0, 0.0, 0.0]),
([Color::gray(0.2), Color::gray(0.8)], "DeviceGray", vec![0.8], vec![0.2]),
// Grey beside CMYK is the K ink, so the shading stays CMYK.
([Color::gray(0.4), Color::cmyk(1.0, 0.0, 0.0, 0.0)], "DeviceCMYK", vec![0.0, 0.0, 0.0, 0.4], vec![1.0, 0.0, 0.0, 0.0]),
] {
for standard in [Standard::None, Standard::PdfX4] {
let report = export_pdf_with_report(
+3 -1
View File
@@ -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;
}
+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
@@ -438,7 +438,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.