From ee33fe3c350281b34dcaa4831993b60f256d5a13 Mon Sep 17 00:00:00 2001 From: Skylar Taylor-Barrick Date: Fri, 9 Oct 2026 17:33:18 +0000 Subject: [PATCH] FID1: prevent mixed gradient color spaces from aborting PDF export --- ROADMAP.md | 2 + crates/pdf/src/export.rs | 29 ++++- crates/pdf/src/lib.rs | 2 + crates/pdf/tests/gradient_color_spaces.rs | 145 ++++++++++++++++++++++ 4 files changed, 174 insertions(+), 4 deletions(-) create mode 100644 crates/pdf/tests/gradient_color_spaces.rs diff --git a/ROADMAP.md b/ROADMAP.md index aa4c17396..b0e6ab946 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,7 @@ # DesignCraft roadmap +- 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. + - 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. diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index 0c77e69b9..d151fad67 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -937,22 +937,43 @@ impl Exporter<'_> { } /// A swatch fill (solid or gradient) in the item's inner space. `None` for [None]/unknown. - fn fill_paint(&self, fill: &designcraft_doc::Fill, bounds: Rect) -> Option { + fn fill_paint(&mut self, fill: &designcraft_doc::Fill, bounds: Rect) -> Option { 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(); + // 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 = Vec::new(); let mut last = 0.0f32; - for (o, c, a) in g.expanded_stops() { + for (o, c, a) in expanded { let o = o.clamp(last, 1.0); last = o; - stops.push(Stop { offset: norm(o), color: device(&c, self.rgb_only), opacity: norm(a) }); + 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; } let c = bounds.center(); let v = fill.gradient_vector; - return Some(match g.kind { + return Some(match kind { GradientKind::Radial => { let (c, r) = match v { Some([x0, y0, x1, y1]) => (designcraft_geom::Point::new(x0, y0), Vec2::new(x1 - x0, y1 - y0).hypot()), diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index b03d8dbe7..080bb5981 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -12,6 +12,8 @@ //! - **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). //! - **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`]). //! - **Text as real text:** the composed glyph runs are emitted with embedded, subsetted fonts and a diff --git a/crates/pdf/tests/gradient_color_spaces.rs b/crates/pdf/tests/gradient_color_spaces.rs new file mode 100644 index 000000000..c523a4187 --- /dev/null +++ b/crates/pdf/tests/gradient_color_spaces.rs @@ -0,0 +1,145 @@ +//! Synthetic regressions for gradients that previously aborted inside the PDF writer. +use designcraft_color::{Color, 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}, +}; + +fn document(colors: &[Color], kind: GradientKind, midpoint: f32, opacity: f32) -> Document { + let mut doc = Document::new(&NewDocument { width: 120.0, height: 120.0, facing_pages: false, ..Default::default() }); + let stops = colors + .iter() + .enumerate() + .map(|(i, &color)| GradientStop { offset: i as f32 / (colors.len() - 1) as f32, color, opacity, midpoint }) + .collect(); + doc.swatches.push(Swatch { + name: "Mixed inks".into(), + value: SwatchValue::Gradient { gradient: Gradient { kind, stops } }, + locked: false, + named: true, + hidden: false, + }); + // Two uses of the same swatch must not duplicate the warning. + for x in [10.0, 65.0] { + let mut item = Item::new(ItemId(doc.alloc()), doc.default_layer(), Shape::Rectangle, shapes::rectangle(Rect::new(x, 10.0, x + 40.0, 100.0))); + item.fill = Fill::swatch("Mixed inks"); + doc.insert_item(SpreadRef::Doc(0), item, None).unwrap(); + } + doc +} + +fn conversion_warnings(warnings: &[String]) -> usize { + warnings.iter().filter(|w| w.contains("Mixed inks") && w.contains("converted to RGB")).count() +} + +/// A subprocess is essential: the original assertion panicked again in Surface::drop and aborted. +#[test] +fn mixed_gradient_export_survives_in_subprocess() { + const CHILD: &str = "DESIGNCRAFT_TEST_MIXED_GRADIENT_CHILD"; + if std::env::var_os(CHILD).is_none() { + let out = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "mixed_gradient_export_survives_in_subprocess", "--nocapture"]) + .env(CHILD, "1") + .output() + .unwrap(); + assert!(out.status.success(), "gradient export child failed: {}\n{}", out.status, String::from_utf8_lossy(&out.stderr)); + assert!(String::from_utf8_lossy(&out.stderr).contains("gradient matrix completed"), "child must execute the regression matrix"); + return; + } + 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 kind in [GradientKind::Linear, GradientKind::Radial] { + for standard in [Standard::None, Standard::PdfX4, Standard::PdfA2b] { + let d = document(&colors, kind, 0.5, 0.7); + let report = export_pdf_with_report(&d, &Cache::new(), &PdfOptions { standard, ..Default::default() }).unwrap(); + assert_eq!(Pdf::new(report.bytes.clone()).unwrap().pages().len(), 1); + let archival_rgb = standard == Standard::PdfA2b && !colors.iter().any(|c| matches!(c, Color::Gray { .. })); + assert_eq!(conversion_warnings(&report.warnings), usize::from(!archival_rgb), "{standard:?}: {:?}", report.warnings); + if standard == Standard::PdfX4 { + assert!( + report.warnings.iter().any(|w| w.starts_with("PDF/X-4:") && w.contains("RGB")), + "RGB conformance warning must survive: {:?}", + report.warnings + ); + } + if standard == Standard::PdfA2b { + assert!( + report.warnings.iter().any(|w| w.contains("PDF/A") && w.contains("RGB")), + "archival policy warning must survive: {:?}", + report.warnings + ); + } + } + } + } + // Expansion inserts an RGB midpoint even when the authoring stops share CMYK or Gray. + 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!(Pdf::new(report.bytes).unwrap().pages().len(), 1); + } + } + } + eprintln!("gradient matrix completed"); +} + +fn endpoint(function: &Dict<'_>, first: bool) -> Vec { + if let Some(functions) = function.get::>(b"Functions") { + let functions: Vec<_> = functions.iter::>().collect(); + return endpoint(if first { functions.first().unwrap() } else { functions.last().unwrap() }, first); + } + function.get::>(if first { b"C0" } else { b"C1" }).unwrap().iter::().collect() +} + +#[test] +fn homogeneous_gradients_keep_device_spaces_and_components() { + for (colors, space, start, end) in [ + ([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]), + ] { + for standard in [Standard::None, Standard::PdfX4] { + let report = export_pdf_with_report( + &document(&colors, GradientKind::Linear, 0.5, 1.0), + &Cache::new(), + &PdfOptions { standard, ..Default::default() }, + ) + .unwrap(); + assert_eq!(conversion_warnings(&report.warnings), 0); + let pdf = Pdf::new(report.bytes).unwrap(); + let patterns = &pdf.pages()[0].resources().patterns; + let mut count = 0; + for (name, _) in patterns.entries() { + let pattern = patterns.get::>(name.as_ref()).unwrap(); + let shading = pattern.get::>(b"Shading").unwrap(); + assert_eq!(shading.get::>(b"ColorSpace").unwrap().as_ref(), space.as_bytes()); + let function = shading.get::>(b"Function").unwrap(); + for (actual, expected) in [(endpoint(&function, true), &start), (endpoint(&function, false), &end)] { + assert_eq!(actual.len(), expected.len()); + assert!(actual.iter().zip(expected).all(|(a, b)| (a - b).abs() < 0.005), "{space}: {actual:?} vs {expected:?}"); + } + count += 1; + } + assert!(count > 0, "inspect actual shading resources, not unrelated blend spaces"); + } + } +} + +#[test] +fn mixed_gradient_booklet_reports_conversion() { + let d = document(&[Color::cmyk(1.0, 0.0, 0.0, 0.0), Color::rgb(1.0, 0.0, 0.0)], GradientKind::Linear, 0.5, 1.0); + let report = designcraft_pdf::export_booklet(&d, &Cache::new(), &Default::default()).unwrap(); + assert_eq!(conversion_warnings(&report.warnings), 1); + assert!(!Pdf::new(report.bytes).unwrap().pages().is_empty()); +}