From 60dc2b54ce6fb62925b9e3aedf540a6d19181fa5 Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 20 Oct 2026 03:39:06 +0000 Subject: [PATCH] krilla: write mesh corner colors in one color space A mesh gradient took its color space and component count from the first patch's first corner, so a later corner in another space was truncated or padded with zeros and written as the wrong color. Mesh corner colors now go through the same rule as gradient stops (`common_color_space`): process colors alone are DeviceCMYK, one spot alone is Separation, and mixes are DeviceN with process colorants first, then spots. Colors that rule can't unify (RGB with CMYK, registration) fail the document with `KrillaError::MixedMeshColorSpaces`. The opacity soft mask is unchanged, and a mesh in one space writes the same bytes as before. patches/10-mesh-gradients.patch holds #184's change and this fix, so patches 01 to 10 reproduce src; the ledger section 10 and SOURCE.json follow. --- crates/pdf/tests/print_color.rs | 116 ++++- vendor/krilla/README.craft.md | 26 +- vendor/krilla/SOURCE.json | 8 +- vendor/krilla/patches/10-mesh-gradients.patch | 397 ++++++++++++++++++ vendor/krilla/src/error.rs | 9 + vendor/krilla/src/graphics/paint.rs | 14 +- .../krilla/src/graphics/shading_function.rs | 37 +- vendor/krilla/src/serialize.rs | 7 + 8 files changed, 592 insertions(+), 22 deletions(-) create mode 100644 vendor/krilla/patches/10-mesh-gradients.patch diff --git a/crates/pdf/tests/print_color.rs b/crates/pdf/tests/print_color.rs index 8a3b92b7..c70cc975 100644 --- a/crates/pdf/tests/print_color.rs +++ b/crates/pdf/tests/print_color.rs @@ -16,7 +16,7 @@ use backend::image::{BitsPerComponent, CustomImage, Image, ImageColorspace}; use backend::mask::{Mask, MaskType, TransferFunction}; use backend::num::NormalizedF32; use backend::page::PageSettings; -use backend::paint::{Fill, LinearGradient, SpreadMethod, Stop, SweepGradient}; +use backend::paint::{Fill, LinearGradient, MeshGradient, MeshPatch, SpreadMethod, Stop, SweepGradient}; use backend::surface::Surface; use backend::{GroupColorSpace, SerializeSettings, Trapped}; use craft_pdf::write::print::{self, PrintColor}; @@ -810,6 +810,120 @@ fn device_n_shading_keeps_a_spot_to_paper_as_separation() { } } +// Mesh gradients (`paint::MeshGradient`, Type 6 shadings). + +/// A square Coons patch with straight edges, corners clockwise from the top left at (x, y). +fn square(x: f32, y: f32, size: f32, colors: [backend::color::Color; 4], opacities: [f32; 4]) -> MeshPatch { + let corners = [(x, y), (x + size, y), (x + size, y + size), (x, y + size)]; + let mut points = [(0.0, 0.0); 12]; + for (i, &(ax, ay)) in corners.iter().enumerate() { + let (bx, by) = corners[(i + 1) % 4]; + for step in 0..3 { + let t = step as f32 / 3.0; + points[3 * i + step] = (ax + (bx - ax) * t, ay + (by - ay) * t); + } + } + MeshPatch { points, colors, opacities: opacities.map(|o| NormalizedF32::new(o).unwrap()) } +} + +fn mesh(patches: Vec) -> backend::paint::Paint { + MeshGradient { patches, transform: Transform::identity(), anti_alias: false }.into() +} + +/// Each Type 6 shading in the file: color space family and colorant names, components a +/// corner (from `/Decode`), and the corner color bytes of each patch. +type MeshSummary = ((String, Vec), usize, Vec>); + +fn mesh_shadings(bytes: &[u8]) -> Vec { + let pdf = hayro_syntax::Pdf::new(Arc::new(bytes.to_vec())).unwrap(); + let mut out = Vec::new(); + for object in pdf.objects() { + let Object::Stream(stream) = object else { continue }; + let dict = stream.dict(); + if dict.get::(b"ShadingType".as_slice()) != Some(6) { + continue; + } + assert_eq!(dict.get::(b"BitsPerCoordinate".as_slice()), Some(32)); + assert_eq!(dict.get::(b"BitsPerComponent".as_slice()), Some(8)); + assert_eq!(dict.get::(b"BitsPerFlag".as_slice()), Some(8)); + let cs = dict.get::>(b"ColorSpace".as_slice()).unwrap(); + let decode = dict.get::>(b"Decode".as_slice()).unwrap().iter::().count(); + let components = (decode - 4) / 2; + let data = stream.decoded().unwrap(); + let patch_len = 1 + 12 * 8 + 4 * components; + assert_eq!(data.len() % patch_len, 0, "whole patches of {components} components a corner"); + let corners = data.chunks(patch_len).map(|patch| patch[1 + 12 * 8..].to_vec()).collect(); + out.push((colorant_names(&cs), components, corners)); + } + out.sort_by(|a, b| a.0.cmp(&b.0)); + out +} + +fn gold_spot(tint: u8) -> backend::color::Color { + use backend::color::separation::{self, SeparationColorant, SeparationSpace}; + separation::Color::new(tint, SeparationSpace::new(SeparationColorant::Custom("Gold".to_owned()), gold().into())).into() +} + +/// Mesh corner colors follow the gradient stop rule across every patch: CMYK, gray and a +/// spot become one DeviceN space with process colorants first, so a later patch's spot is +/// written as itself. Corner opacities below 1 add the same mesh in gray as a soft mask. +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +#[cfg_attr(not(target_arch = "wasm32"), test)] +fn mesh_mixing_process_and_spot_corners_is_a_device_n_shading() { + let cyan = || -> backend::color::Color { cmyk::Color::new(255, 0, 0, 0).into() }; + let paper = || -> backend::color::Color { cmyk::Color::new(0, 0, 0, 0).into() }; + let patches = |opacity: f32| { + vec![ + square(10.0, 10.0, 40.0, [cyan(), cyan(), luma::Color::black().into(), paper()], [1.0; 4]), + square(50.0, 10.0, 40.0, [gold_spot(255), gold_spot(128), cyan(), cyan()], [1.0, 1.0, 1.0, opacity]), + ] + }; + let names = |v: &[&str]| v.iter().map(|s| (*s).to_owned()).collect::>(); + let expected = vec![vec![255, 0, 0, 255, 0, 0, 0, 255, 0, 0, 0, 0], vec![0, 0, 255, 0, 0, 128, 255, 0, 0, 255, 0, 0]]; + + let opaque = page_with(|surface| fill_rect(surface, 0.0, mesh(patches(1.0)))); + assert_eq!(mesh_shadings(&opaque), vec![(("DeviceN".to_owned(), names(&["Cyan", "Black", "Gold"])), 3, expected.clone())]); + + let translucent = page_with(|surface| fill_rect(surface, 0.0, mesh(patches(0.5)))); + let found = mesh_shadings(&translucent); + assert_eq!(found.len(), 2, "{found:?}"); + assert_eq!(found[0], (("DeviceGray".to_owned(), vec![]), 1, vec![vec![255; 4], vec![255, 255, 255, 128]])); + assert_eq!(found[1], (("DeviceN".to_owned(), names(&["Cyan", "Black", "Gold"])), 3, expected)); + assert_eq!(soft_masks(&translucent).len(), 1); +} + +/// RGB corners can't share a space with CMYK ones: the document fails instead of writing +/// one patch's colors in the other's space. +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +#[cfg_attr(not(target_arch = "wasm32"), test)] +fn mesh_mixing_rgb_and_cmyk_corners_is_refused() { + let red: backend::color::Color = backend::color::rgb::Color::new(255, 0, 0).into(); + let cyan: backend::color::Color = cmyk::Color::new(255, 0, 0, 0).into(); + let patches = vec![ + square(10.0, 10.0, 40.0, [red.clone(), red.clone(), red.clone(), red.clone()], [1.0; 4]), + square(50.0, 10.0, 40.0, [cyan.clone(), cyan.clone(), red.clone(), red], [1.0; 4]), + ]; + let mut writer = Writer::new(cmyk_settings(), Metadata::default()); + let mut page = writer.document_mut().start_page_with(PageSettings::new(Size::from_wh(300.0, 60.0).unwrap())); + let mut surface = page.surface(); + fill_rect(&mut surface, 0.0, mesh(patches)); + surface.finish(); + page.finish(); + let Err(WriteError::Serialization(error)) = writer.finish() else { panic!("a mixed RGB and CMYK mesh was written") }; + assert!(matches!(*error, backend::error::KrillaError::MixedMeshColorSpaces(_)), "{error:?}"); +} + +/// A mesh in one color space is written in that space, three bytes a corner for RGB. +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +#[cfg_attr(not(target_arch = "wasm32"), test)] +fn rgb_mesh_is_a_device_rgb_shading() { + let rgb = |r, g, b| -> backend::color::Color { backend::color::rgb::Color::new(r, g, b).into() }; + let patch = square(10.0, 10.0, 40.0, [rgb(255, 0, 0), rgb(0, 255, 0), rgb(0, 0, 255), rgb(255, 255, 255)], [1.0; 4]); + let bytes = page_with(|surface| fill_rect(surface, 0.0, mesh(vec![patch]))); + let corners = vec![vec![255, 0, 0, 0, 255, 0, 0, 0, 255, 255, 255, 255]]; + assert_eq!(mesh_shadings(&bytes), vec![(("DeviceRGB".to_owned(), vec![]), 3, corners)]); +} + const FOGRA39: &[u8] = include_bytes!("../../cms/profiles/craft-coated-fogra39.icc"); fn text(dict: &Dict<'_>, key: &[u8]) -> Option { diff --git a/vendor/krilla/README.craft.md b/vendor/krilla/README.craft.md index 313dac6a..40b8dae1 100644 --- a/vendor/krilla/README.craft.md +++ b/vendor/krilla/README.craft.md @@ -19,7 +19,7 @@ itself omits those texts. Its examples and four CC0 ICC profiles are retained wi | `patches/07-device-n-images-shadings.patch` | DeviceN images and shadings | `graphics/{image,paint,shading_function}.rs` | | `patches/08-print-output-intent.patch` | Print output intent and PDF/X markers | `chunk_container.rs`, `interchange/metadata.rs`, `lib.rs`, `serialize.rs` | | `patches/09-rendition-class.patch` | Master rendition in XMP | `chunk_container.rs` | -| `patches/10-mesh-gradients.patch` | Mesh gradients | `content.rs`, `graphics/{mask,paint,shading_function}.rs`, `surface.rs` | +| `patches/10-mesh-gradients.patch` | Mesh gradients | `content.rs`, `error.rs`, `graphics/{mask,paint,shading_function}.rs`, `serialize.rs`, `surface.rs` | Each patch applies with `patch -p1` from this directory, in numeric order, to the pristine crate. `SOURCE.json` records, for every retained file, the upstream hash (`upstream_sha256`) @@ -250,17 +250,25 @@ master rendition, so the patch writes `default`. From storytold/craft-libs#184 by @fastmikey99. -Krilla 0.8.2 paints only flat colour, linear, radial and sweep gradients and tiling patterns, so -an app writing a gradient mesh had to cut each patch into flat-colour pieces: a VectorCraft +Krilla 0.8.2 paints only flat color, linear, radial and sweep gradients and tiling patterns, so +an app writing a gradient mesh had to cut each patch into flat-color pieces: a VectorCraft document holding an Illustrator file's 22,850 mesh patches became a 197 MB PDF with stepped -blends. The patch adds `paint::MeshGradient` (and `MeshPatch`): Coons patches with a colour and +blends. The patch adds `paint::MeshGradient` (and `MeshPatch`): Coons patches with a color and an opacity at each corner, written as one PDF Type 6 shading (ISO 32000-1, 8.7.4.5.7) through pdf-writer's `StreamShading`, so the viewer or RIP blends them at its own resolution. Each patch is an 8-bit flag (no shared edge), its twelve points as 32-bit coordinates over the mesh's -bounds (`Decode`) and its four corner colours at 8 bits a component, Flate-compressed. It goes +bounds (`Decode`) and its four corner colors at 8 bits a component, Flate-compressed. It goes through the same path as the other gradients (`GradientProperties::MeshGradient`): a shading pattern for the fill, and for corner opacities below 1 a luminosity soft mask holding the same -mesh in gray (`use_opacities`), as cairo and Illustrator write transparent meshes. Every patch's -colours must share one colour space, as gradient stops must. Changed files: `src/content.rs`, -`src/surface.rs`, `src/graphics/mask.rs`, `src/graphics/paint.rs`, -`src/graphics/shading_function.rs`. +mesh in gray (`use_opacities`), as cairo and Illustrator write transparent meshes. + +The corner colors of every patch go through `common_color_space` from patch 07, the rule +gradient stops follow, before the shading is written: process colors alone become +DeviceCMYK, one spot alone a Separation space, and any other mix of inks a DeviceN space with +the process colorants in CMYK order, then the spots. A mesh mixing CMYK and a spot is a +DeviceN mesh with one component per colorant at each corner. Colors the rule can't bring into +one space (RGB with CMYK, `All` or `None` with anything else) would be written with another +space's component count, so the document fails with `KrillaError::MixedMeshColorSpaces` +instead. The soft mask is written in gray from the opacities and is unaffected. + +Tests: `crates/pdf/tests/print_color.rs` (`mesh_*`, `rgb_mesh_*`) and the plate-test scene S17. diff --git a/vendor/krilla/SOURCE.json b/vendor/krilla/SOURCE.json index f38eadea..789facb2 100644 --- a/vendor/krilla/SOURCE.json +++ b/vendor/krilla/SOURCE.json @@ -137,7 +137,7 @@ { "path": "src/error.rs", "upstream_sha256": "656b260bd99d250535099f17f2db8e6e6135e72c74fb6dff304371db9f646be5", - "sha256": "cedd848f73d9414bf70fb73e310ddc03331dc5b6bd1372e2a212070550e66580" + "sha256": "fdc6eaecce55bfd689f5034b57e9afaa3a3a21ea0444b95c409ac41190b61a4c" }, { "path": "src/geom.rs", @@ -192,7 +192,7 @@ { "path": "src/graphics/paint.rs", "upstream_sha256": "c3de4b7056f3f952009f39f0e0d0311bc9af54807660f9e46f67c177572edf45", - "sha256": "78db7aee7e93670b63272ba7e04b55057520cfcc314c7ccac26f35224efe9fa9" + "sha256": "4154beecc810f3d609d2c46ce82ec63b3421c199f01d922f28b9ed9fa4c7a1e5" }, { "path": "src/graphics/separation.rs", @@ -202,7 +202,7 @@ { "path": "src/graphics/shading_function.rs", "upstream_sha256": "0e0fc8bb1a32272457ef560fa80933972d0f5ef5fb7046424c6de48093cfd978", - "sha256": "84eab9895c6ead8787b9a95f245cbb345ed88cfb132a35c931902ceb91a8c5c3" + "sha256": "2831ec0d78a755db9311a16d01afd5c9f714e4d43bdf69b42c0cb291387ca743" }, { "path": "src/graphics/shading_pattern.rs", @@ -312,7 +312,7 @@ { "path": "src/serialize.rs", "upstream_sha256": "4f2c439dbd20006eea112e056b2f1a5ba98a1ede9227a45f66a6df46fb1a07e8", - "sha256": "9e3d5bf6bde7da3e4cd4dcb6305a9c0b74c64977987f3136b91efcc2cf5a64df" + "sha256": "bbff10ecb0cb48683d567021405ed2b2c83cf259d2c02d4dfaccfe683eedd5d4" }, { "path": "src/stream.rs", diff --git a/vendor/krilla/patches/10-mesh-gradients.patch b/vendor/krilla/patches/10-mesh-gradients.patch new file mode 100644 index 00000000..fb6c3577 --- /dev/null +++ b/vendor/krilla/patches/10-mesh-gradients.patch @@ -0,0 +1,397 @@ +diff --git a/src/content.rs b/src/content.rs +index f723f20..26ad665 100644 +--- a/src/content.rs ++++ b/src/content.rs +@@ -1122,6 +1122,16 @@ impl ContentBuilder { + let (gradient_props, transform) = sg.clone().gradient_properties(bounds); + write_gradient(gradient_props, sc, chunk_container, transform, self); + } ++ InnerPaint::MeshGradient(mg) => { ++ let transform = mg.transform; ++ write_gradient( ++ GradientProperties::MeshGradient(mg.clone()), ++ sc, ++ chunk_container, ++ transform, ++ self, ++ ); ++ } + InnerPaint::Pattern(pat) => { + let mut pat = Arc::unwrap_or_clone(pat.clone()); + pat.transform = pattern_transform(pat.transform); +diff --git a/src/error.rs b/src/error.rs +index 1807ac3..4c582ae 100644 +--- a/src/error.rs ++++ b/src/error.rs +@@ -29,6 +29,11 @@ pub enum KrillaError { + /// + /// [`GroupColorSpace::Cmyk`]: crate::GroupColorSpace::Cmyk + MissingGroupCmykProfile, ++ /// A mesh gradient's corner colors are in color spaces that can't be written as one ++ /// (see [`MeshGradient`]), for example RGB with CMYK. ++ /// ++ /// [`MeshGradient`]: crate::paint::MeshGradient ++ MixedMeshColorSpaces(Option), + /// An error while attempting to embed a font. + Font(Font, String), + /// A list of validation errors. Can only occur if you set the `validator` in +@@ -70,6 +75,10 @@ impl Display for KrillaError { + KrillaError::MissingGroupCmykProfile => { + f.write_str("a CMYK group color space with no_device_cs needs a CMYK profile") + } ++ KrillaError::MixedMeshColorSpaces(location) => { ++ f.write_str("a mesh gradient mixes color spaces that can't be written as one")?; ++ write_location(f, *location) ++ } + KrillaError::Font(_, message) => write!(f, "failed to embed font: {message}"), + KrillaError::Validation(errors) => { + let count = errors.len(); +diff --git a/src/graphics/mask.rs b/src/graphics/mask.rs +index 38b9ad0..2654b25 100644 +--- a/src/graphics/mask.rs ++++ b/src/graphics/mask.rs +@@ -99,6 +99,15 @@ impl Mask { + return None; + } + } ++ GradientProperties::MeshGradient(mg) => { ++ if mg ++ .patches ++ .iter() ++ .all(|p| p.opacities.iter().all(|o| o.get() == 1.0)) ++ { ++ return None; ++ } ++ } + } + + let shading_function = ShadingFunction::new(gradient_properties, true); +diff --git a/src/graphics/paint.rs b/src/graphics/paint.rs +index d4b9efb..abe8892 100644 +--- a/src/graphics/paint.rs ++++ b/src/graphics/paint.rs +@@ -52,6 +52,65 @@ impl Hash for LinearGradient { + } + } + ++/// One Coons patch of a [`MeshGradient`]. ++#[derive(Debug, Clone, PartialEq)] ++pub struct MeshPatch { ++ /// The twelve boundary control points, in the order a PDF Type 6 shading lists them ++ /// (ISO 32000-1, 8.7.4.5.7): starting at a corner, three cubic Bézier edges' worth of points ++ /// going one way round the patch, so the corners are `points[0]`, `[3]`, `[6]` and `[9]`. ++ pub points: [(f32, f32); 12], ++ /// The colors at the corners `points[0]`, `[3]`, `[6]` and `[9]`. ++ /// ++ /// Corner colors of a mesh in different color spaces built from inks (CMYK, luma as ++ /// black, spots with a CMYK fallback, DeviceN) are written in one space: Separation for a ++ /// single spot, DeviceCMYK for process colors alone, DeviceN otherwise. Other colors need ++ /// to share one color space across the whole mesh, or [`Document::finish`] fails with ++ /// [`KrillaError::MixedMeshColorSpaces`]. ++ /// ++ /// [`Document::finish`]: crate::Document::finish ++ /// [`KrillaError::MixedMeshColorSpaces`]: crate::error::KrillaError::MixedMeshColorSpaces ++ pub colors: [Color; 4], ++ /// The opacities at the same corners. ++ pub opacities: [NormalizedF32; 4], ++} ++ ++impl Eq for MeshPatch {} ++ ++impl Hash for MeshPatch { ++ fn hash(&self, state: &mut H) { ++ for (x, y) in &self.points { ++ x.to_bits().hash(state); ++ y.to_bits().hash(state); ++ } ++ self.colors.hash(state); ++ self.opacities.hash(state); ++ } ++} ++ ++/// A mesh gradient: Coons patches, each blending its four corner colors across its curved ++/// shape, written as one PDF Type 6 shading so the viewer or RIP blends them smoothly at its ++/// own resolution. Corner opacities below 1 add a soft mask, as for the other gradients. ++/// Patches paint in order (a later one over an earlier one); a fill paints only where they lie. ++#[derive(Debug, Clone, PartialEq)] ++pub struct MeshGradient { ++ /// The patches. ++ pub patches: Vec, ++ /// A transform that should be applied to the mesh gradient. ++ pub transform: Transform, ++ /// Whether the gradient should be anti-aliased. ++ pub anti_alias: bool, ++} ++ ++impl Eq for MeshGradient {} ++ ++impl Hash for MeshGradient { ++ fn hash(&self, state: &mut H) { ++ self.patches.hash(state); ++ self.transform.hash(state); ++ self.anti_alias.hash(state); ++ } ++} ++ + /// A radial gradient. + #[derive(Debug, Clone, PartialEq)] + pub struct RadialGradient { +@@ -178,6 +237,7 @@ pub(crate) enum InnerPaint { + LinearGradient(LinearGradient), + RadialGradient(RadialGradient), + SweepGradient(SweepGradient), ++ MeshGradient(Arc), + Pattern(Arc), + } + +@@ -220,6 +280,12 @@ impl From for Paint { + } + } + ++impl From for Paint { ++ fn from(value: MeshGradient) -> Self { ++ Paint(InnerPaint::MeshGradient(Arc::new(value))) ++ } ++} ++ + impl From for Paint { + fn from(value: Color) -> Self { + Paint(InnerPaint::Color(value)) +diff --git a/src/graphics/shading_function.rs b/src/graphics/shading_function.rs +index ad0e783..74c56f3 100644 +--- a/src/graphics/shading_function.rs ++++ b/src/graphics/shading_function.rs +@@ -4,6 +4,7 @@ use std::sync::Arc; + + use bumpalo::Bump; + use pdf_writer::types::{FunctionShadingType, PostScriptOp}; ++use pdf_writer::writers::StreamShadingType; + use pdf_writer::{Chunk, Finish, Ref}; + use tiny_skia_path::Point; + +@@ -14,7 +15,7 @@ use crate::graphics::color::device_n::{self, Colorant, DeviceNSpace, Process}; + use crate::graphics::color::separation::{self, SeparationColorant, SeparationSpace}; + use crate::graphics::color::{cmyk, luma}; + use crate::graphics::color::{Color, ColorSpace, RegularColor, SpecialColor}; +-use crate::graphics::paint::{LinearGradient, RadialGradient, SweepGradient}; ++use crate::graphics::paint::{LinearGradient, MeshGradient, RadialGradient, SweepGradient}; + use crate::graphics::paint::{SpreadMethod, Stop}; + use crate::num::NormalizedF32; + use crate::resource; +@@ -84,6 +85,7 @@ impl Hash for PostScriptGradient { + pub(crate) enum GradientProperties { + RadialAxialGradient(RadialAxialGradient), + PostScriptGradient(PostScriptGradient), ++ MeshGradient(Arc), + } + + impl GradientProperties { +@@ -100,6 +102,7 @@ impl GradientProperties { + return Some((&psg.stops[0].color, psg.stops[0].opacity)); + } + } ++ GradientProperties::MeshGradient(_) => {} + } + + None +@@ -233,8 +236,8 @@ impl ShadingFunction { + } + + impl GradientProperties { +- /// Rewrite stops in different ink-based color spaces into one space (see +- /// [`common_color_space`]); other gradients are returned unchanged. ++ /// Rewrite stops, or mesh corner colors, in different ink-based color spaces into one ++ /// space (see [`common_color_space`]); other gradients are returned unchanged. + fn with_common_color_space(self) -> Self { + fn convert(stops: &mut [Stop]) { + let colors: Vec = stops.iter().map(|s| s.color.clone()).collect(); +@@ -254,6 +257,22 @@ impl GradientProperties { + convert(&mut psg.stops); + GradientProperties::PostScriptGradient(psg) + } ++ GradientProperties::MeshGradient(mesh) => { ++ let colors: Vec = mesh ++ .patches ++ .iter() ++ .flat_map(|p| p.colors.iter().cloned()) ++ .collect(); ++ let Some(colors) = common_color_space(&colors) else { ++ return GradientProperties::MeshGradient(mesh); ++ }; ++ let mut mesh = Arc::unwrap_or_clone(mesh); ++ let corners = mesh.patches.iter_mut().flat_map(|p| p.colors.iter_mut()); ++ for (corner, color) in corners.zip(colors) { ++ *corner = color; ++ } ++ GradientProperties::MeshGradient(Arc::new(mesh)) ++ } + } + } + } +@@ -478,6 +497,35 @@ impl Cacheable for ShadingFunction { + registered_cs, + ) + } ++ GradientProperties::MeshGradient(mg) => { ++ // No patches paints nothing; the shading is still written, as it is referenced. ++ let first = mg ++ .patches ++ .first() ++ .map_or_else(|| luma::Color::black().into(), |p| p.colors[0].clone()); ++ // `ShadingFunction::new` has brought ink-based colors into one space; any ++ // others left can't share the stream's one color space. ++ if !self.0.use_opacities ++ && sc.mixed_mesh_colors.is_none() ++ && !mg ++ .patches ++ .iter() ++ .flat_map(|p| p.colors.iter()) ++ .all(|c| same_color_space(&first, c)) ++ { ++ sc.mixed_mesh_colors = Some(sc.location); ++ } ++ let shading_cs = shading_color_space(sc, first, self.0.use_opacities); ++ let registered_cs = sc.register_colorspace(chunk_container, shading_cs); ++ serialize_mesh_shading( ++ sc, ++ &mut stream_chunk, ++ root_ref, ++ mg, ++ self.0.use_opacities, ++ registered_cs, ++ ); ++ } + } + + // Note: The stream chunk might be empty. +@@ -497,6 +545,83 @@ fn shading_color_space(sc: &mut SerializeContext, color: Color, use_opacities: b + } + } + ++/// A mesh gradient as a Type 6 (Coons patch mesh) shading: per patch an 8-bit edge flag (0, no ++/// shared edge), the twelve points as 32-bit coordinates over the mesh's bounds (`Decode`), and ++/// the four corner colors (with `use_opacities`, the opacities as gray) at 8 bits a component, ++/// Flate-compressed. 32-bit coordinates keep the patches where they are to well under a printer ++/// dot, as Illustrator writes its meshes. ++fn serialize_mesh_shading( ++ sc: &mut SerializeContext, ++ chunk: &mut Chunk, ++ root_ref: Ref, ++ mesh: &MeshGradient, ++ use_opacities: bool, ++ cs: MaybeDeviceColorSpace, ++) { ++ let (mut x0, mut y0, mut x1, mut y1) = (f32::MAX, f32::MAX, f32::MIN, f32::MIN); ++ for (x, y) in mesh.patches.iter().flat_map(|p| p.points.iter()) { ++ (x0, y0, x1, y1) = (x0.min(*x), y0.min(*y), x1.max(*x), y1.max(*y)); ++ } ++ if !(x0.is_finite() && y0.is_finite() && x1.is_finite() && y1.is_finite()) { ++ (x0, y0, x1, y1) = (0.0, 0.0, 1.0, 1.0); ++ } ++ // A zero-size range would divide by zero: widen it by a point either way. ++ if x1 - x0 < f32::EPSILON { ++ (x0, x1) = (x0 - 1.0, x1 + 1.0); ++ } ++ if y1 - y0 < f32::EPSILON { ++ (y0, y1) = (y0 - 1.0, y1 + 1.0); ++ } ++ let components = if use_opacities { ++ 1 ++ } else { ++ mesh.patches ++ .first() ++ .map_or(1, |p| p.colors[0].to_pdf_color().len()) ++ }; ++ let coord = |v: f32, lo: f32, hi: f32| { ++ (((f64::from(v) - f64::from(lo)) / (f64::from(hi) - f64::from(lo))).clamp(0.0, 1.0) ++ * f64::from(u32::MAX)) ++ .round() as u32 ++ }; ++ let byte = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8; ++ let mut data = Vec::with_capacity(mesh.patches.len() * (1 + 12 * 8 + 4 * components)); ++ for patch in &mesh.patches { ++ data.push(0u8); ++ for (x, y) in &patch.points { ++ data.extend(coord(*x, x0, x1).to_be_bytes()); ++ data.extend(coord(*y, y0, y1).to_be_bytes()); ++ } ++ for (color, opacity) in patch.colors.iter().zip(patch.opacities.iter()) { ++ if use_opacities { ++ data.push(byte(opacity.get())); ++ } else { ++ let values = color.to_pdf_color(); ++ // Mixed spaces fail the document (`mixed_mesh_colors`); still never misread bytes. ++ for i in 0..components { ++ data.push(byte(values.get(i).copied().unwrap_or(0.0))); ++ } ++ } ++ } ++ } ++ let filter_stream = ++ FilterStreamBuilder::new_from_binary_data(&data).finish(&sc.serialize_settings()); ++ let mut shading = chunk.stream_shading(root_ref, filter_stream.encoded_data()); ++ filter_stream.write_filters(shading.deref_mut().deref_mut()); ++ shading.shading_type(StreamShadingType::CoonsPatch); ++ set_colorspace(cs, shading.deref_mut().deref_mut()); ++ shading.bits_per_coordinate(32); ++ shading.bits_per_component(8); ++ shading.bits_per_flag(8); ++ let mut decode = vec![x0, x1, y0, y1]; ++ for _ in 0..components { ++ decode.extend([0.0, 1.0]); ++ } ++ shading.decode(decode); ++ shading.anti_alias(mesh.anti_alias); ++ shading.finish(); ++} ++ + fn serialize_postscript_shading( + sc: &mut SerializeContext, + chunk: &mut Chunk, +diff --git a/src/serialize.rs b/src/serialize.rs +index 378d6ec..0093a25 100644 +--- a/src/serialize.rs ++++ b/src/serialize.rs +@@ -341,6 +341,9 @@ pub(crate) enum MaybeDeviceColorSpace { + pub(crate) struct SerializeContext { + /// An unfinished or failed page/surface must never serialize as a complete document. + pub(crate) poisoned: bool, ++ /// Where the first mesh gradient whose corner colors share no color space was drawn; ++ /// [`SerializeContext::finish`] fails with [`KrillaError::MixedMeshColorSpaces`]. ++ pub(crate) mixed_mesh_colors: Option>, + /// The ref of the page tree. + page_tree_ref: Ref, + /// PDF 2.0 namespaces. +@@ -401,6 +404,7 @@ impl SerializeContext { + + Self { + poisoned: false, ++ mixed_mesh_colors: None, + cached_mappings: HashMap::new(), + pdf2_ns, + global_objects: GlobalObjects::default(), +@@ -594,6 +598,9 @@ impl SerializeContext { + { + return Err(KrillaError::MissingGroupCmykProfile); + } ++ if let Some(location) = self.mixed_mesh_colors { ++ return Err(KrillaError::MixedMeshColorSpaces(location)); ++ } + + // We need to be careful here that we serialize the objects in the right order, + // as in some cases we use MaybeTake::take to remove an object, which means that +diff --git a/src/surface.rs b/src/surface.rs +index 0cbbc4b..15a728d 100644 +--- a/src/surface.rs ++++ b/src/surface.rs +@@ -615,6 +615,10 @@ impl<'a> Surface<'a> { + r.stops.iter().any(|s| s.opacity != NormalizedF32::ONE) + } + InnerPaint::SweepGradient(r) => r.stops.iter().any(|s| s.opacity != NormalizedF32::ONE), ++ InnerPaint::MeshGradient(m) => m ++ .patches ++ .iter() ++ .any(|p| p.opacities.iter().any(|o| *o != NormalizedF32::ONE)), + InnerPaint::Pattern(_) => false, + }; + diff --git a/vendor/krilla/src/error.rs b/vendor/krilla/src/error.rs index 1807ac3a..4c582aeb 100644 --- a/vendor/krilla/src/error.rs +++ b/vendor/krilla/src/error.rs @@ -29,6 +29,11 @@ pub enum KrillaError { /// /// [`GroupColorSpace::Cmyk`]: crate::GroupColorSpace::Cmyk MissingGroupCmykProfile, + /// A mesh gradient's corner colors are in color spaces that can't be written as one + /// (see [`MeshGradient`]), for example RGB with CMYK. + /// + /// [`MeshGradient`]: crate::paint::MeshGradient + MixedMeshColorSpaces(Option), /// An error while attempting to embed a font. Font(Font, String), /// A list of validation errors. Can only occur if you set the `validator` in @@ -70,6 +75,10 @@ impl Display for KrillaError { KrillaError::MissingGroupCmykProfile => { f.write_str("a CMYK group color space with no_device_cs needs a CMYK profile") } + KrillaError::MixedMeshColorSpaces(location) => { + f.write_str("a mesh gradient mixes color spaces that can't be written as one")?; + write_location(f, *location) + } KrillaError::Font(_, message) => write!(f, "failed to embed font: {message}"), KrillaError::Validation(errors) => { let count = errors.len(); diff --git a/vendor/krilla/src/graphics/paint.rs b/vendor/krilla/src/graphics/paint.rs index f79c1826..abe88920 100644 --- a/vendor/krilla/src/graphics/paint.rs +++ b/vendor/krilla/src/graphics/paint.rs @@ -59,8 +59,16 @@ pub struct MeshPatch { /// (ISO 32000-1, 8.7.4.5.7): starting at a corner, three cubic Bézier edges' worth of points /// going one way round the patch, so the corners are `points[0]`, `[3]`, `[6]` and `[9]`. pub points: [(f32, f32); 12], - /// The colours at the corners `points[0]`, `[3]`, `[6]` and `[9]`. Every patch of a mesh - /// gradient needs its colours in the same colour space. + /// The colors at the corners `points[0]`, `[3]`, `[6]` and `[9]`. + /// + /// Corner colors of a mesh in different color spaces built from inks (CMYK, luma as + /// black, spots with a CMYK fallback, DeviceN) are written in one space: Separation for a + /// single spot, DeviceCMYK for process colors alone, DeviceN otherwise. Other colors need + /// to share one color space across the whole mesh, or [`Document::finish`] fails with + /// [`KrillaError::MixedMeshColorSpaces`]. + /// + /// [`Document::finish`]: crate::Document::finish + /// [`KrillaError::MixedMeshColorSpaces`]: crate::error::KrillaError::MixedMeshColorSpaces pub colors: [Color; 4], /// The opacities at the same corners. pub opacities: [NormalizedF32; 4], @@ -79,7 +87,7 @@ impl Hash for MeshPatch { } } -/// A mesh gradient: Coons patches, each blending its four corner colours across its curved +/// A mesh gradient: Coons patches, each blending its four corner colors across its curved /// shape, written as one PDF Type 6 shading so the viewer or RIP blends them smoothly at its /// own resolution. Corner opacities below 1 add a soft mask, as for the other gradients. /// Patches paint in order (a later one over an earlier one); a fill paints only where they lie. diff --git a/vendor/krilla/src/graphics/shading_function.rs b/vendor/krilla/src/graphics/shading_function.rs index 72d84814..74c56f36 100644 --- a/vendor/krilla/src/graphics/shading_function.rs +++ b/vendor/krilla/src/graphics/shading_function.rs @@ -236,8 +236,8 @@ impl ShadingFunction { } impl GradientProperties { - /// Rewrite stops in different ink-based color spaces into one space (see - /// [`common_color_space`]); other gradients are returned unchanged. + /// Rewrite stops, or mesh corner colors, in different ink-based color spaces into one + /// space (see [`common_color_space`]); other gradients are returned unchanged. fn with_common_color_space(self) -> Self { fn convert(stops: &mut [Stop]) { let colors: Vec = stops.iter().map(|s| s.color.clone()).collect(); @@ -257,7 +257,22 @@ impl GradientProperties { convert(&mut psg.stops); GradientProperties::PostScriptGradient(psg) } - mesh @ GradientProperties::MeshGradient(_) => mesh, + GradientProperties::MeshGradient(mesh) => { + let colors: Vec = mesh + .patches + .iter() + .flat_map(|p| p.colors.iter().cloned()) + .collect(); + let Some(colors) = common_color_space(&colors) else { + return GradientProperties::MeshGradient(mesh); + }; + let mut mesh = Arc::unwrap_or_clone(mesh); + let corners = mesh.patches.iter_mut().flat_map(|p| p.colors.iter_mut()); + for (corner, color) in corners.zip(colors) { + *corner = color; + } + GradientProperties::MeshGradient(Arc::new(mesh)) + } } } } @@ -488,6 +503,18 @@ impl Cacheable for ShadingFunction { .patches .first() .map_or_else(|| luma::Color::black().into(), |p| p.colors[0].clone()); + // `ShadingFunction::new` has brought ink-based colors into one space; any + // others left can't share the stream's one color space. + if !self.0.use_opacities + && sc.mixed_mesh_colors.is_none() + && !mg + .patches + .iter() + .flat_map(|p| p.colors.iter()) + .all(|c| same_color_space(&first, c)) + { + sc.mixed_mesh_colors = Some(sc.location); + } let shading_cs = shading_color_space(sc, first, self.0.use_opacities); let registered_cs = sc.register_colorspace(chunk_container, shading_cs); serialize_mesh_shading( @@ -520,7 +547,7 @@ fn shading_color_space(sc: &mut SerializeContext, color: Color, use_opacities: b /// A mesh gradient as a Type 6 (Coons patch mesh) shading: per patch an 8-bit edge flag (0, no /// shared edge), the twelve points as 32-bit coordinates over the mesh's bounds (`Decode`), and -/// the four corner colours (with `use_opacities`, the opacities as gray) at 8 bits a component, +/// the four corner colors (with `use_opacities`, the opacities as gray) at 8 bits a component, /// Flate-compressed. 32-bit coordinates keep the patches where they are to well under a printer /// dot, as Illustrator writes its meshes. fn serialize_mesh_shading( @@ -570,7 +597,7 @@ fn serialize_mesh_shading( data.push(byte(opacity.get())); } else { let values = color.to_pdf_color(); - // A colour from another space can't be written in this one: never misread bytes. + // Mixed spaces fail the document (`mixed_mesh_colors`); still never misread bytes. for i in 0..components { data.push(byte(values.get(i).copied().unwrap_or(0.0))); } diff --git a/vendor/krilla/src/serialize.rs b/vendor/krilla/src/serialize.rs index 378d6ecb..0093a25f 100644 --- a/vendor/krilla/src/serialize.rs +++ b/vendor/krilla/src/serialize.rs @@ -341,6 +341,9 @@ pub(crate) enum MaybeDeviceColorSpace { pub(crate) struct SerializeContext { /// An unfinished or failed page/surface must never serialize as a complete document. pub(crate) poisoned: bool, + /// Where the first mesh gradient whose corner colors share no color space was drawn; + /// [`SerializeContext::finish`] fails with [`KrillaError::MixedMeshColorSpaces`]. + pub(crate) mixed_mesh_colors: Option>, /// The ref of the page tree. page_tree_ref: Ref, /// PDF 2.0 namespaces. @@ -401,6 +404,7 @@ impl SerializeContext { Self { poisoned: false, + mixed_mesh_colors: None, cached_mappings: HashMap::new(), pdf2_ns, global_objects: GlobalObjects::default(), @@ -594,6 +598,9 @@ impl SerializeContext { { return Err(KrillaError::MissingGroupCmykProfile); } + if let Some(location) = self.mixed_mesh_colors { + return Err(KrillaError::MixedMeshColorSpaces(location)); + } // We need to be careful here that we serialize the objects in the right order, // as in some cases we use MaybeTake::take to remove an object, which means that