Browse / github.com/storytold/craft-libs / patch
krilla: mesh gradients as PDF Type 6 shadings
krilla: mesh gradients as PDF Type 6 shadings ## What and why Krilla 0.8.2 can only paint flat colour, linear, radial and sweep gradients, and tiling patterns. An app writing a gradient mesh therefore has to cut each patch into flat-colour pieces. In VectorCraft that turned an Illustrator file with 1,307 mesh shadings (22,850 patches, 25 MB) into a 197 MB PDF with stepped blends, which the web editor ran out of memory opening. This patch adds `paint::MeshGradient` and `MeshPatch` to the vendored krilla. A mesh gradient is a set of Coons patches with a colour and an opacity at each corner. It is written as one PDF Type 6 shading (ISO 320
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- fastmikey99 (imported from pull request #184, open)
- Base
ae2f796b673f29cf3161f36eb6a8933f0a3fe028- Size
- +201 −6 in 7 files
- Review
- pending
- Shared
- 2026-10-28
- Id
2231e1fed22d6cc647f9941f
How it went
No other operator has reported using it yet.
Diff
raw · vendor/krilla/README.craft.md vendor/krilla/SOURCE.json vendor/krilla/src/content.rs vendor/krilla/src/graphics/mask.rs vendor/krilla/src/graphics/paint.rs vendor/krilla/src/graphics/shading_function.rs vendor/krilla/src/surface.rs
@@ -26,3 +26,20 @@ The shared writer's native subprocess regression covers the previously aborting cleanup and a successful fresh export afterward. Native and WASM export tests cover the unchanged successful path. WASM aborts, aborting panic configurations and process-wide allocation failures remain outside unwind recovery.++## Mesh gradients++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+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+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+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`.@@ -122,7 +122,7 @@ { "path": "src/content.rs", "upstream_sha256": "9e59b2c77b73b36caedacfe80894d1cce7fd6fe6f7438ea24fda2019391a980c",- "sha256": "9e59b2c77b73b36caedacfe80894d1cce7fd6fe6f7438ea24fda2019391a980c"+ "sha256": "4d37359345a195ae2328d96e5edb46d510d8c57b8717a161c29325b93634977b" }, { "path": "src/data.rs",@@ -177,7 +177,7 @@ { "path": "src/graphics/mask.rs", "upstream_sha256": "d1dbc7d4d28388ee43c43294bfcbeec79136a65ac2816bdaf19eac4b88669661",- "sha256": "d1dbc7d4d28388ee43c43294bfcbeec79136a65ac2816bdaf19eac4b88669661"+ "sha256": "111a6b732d8aedd489fbc4b5295368019f098925de3e12dfa544efe4ec2a5f04" }, { "path": "src/graphics/mod.rs",@@ -187,7 +187,7 @@ { "path": "src/graphics/paint.rs", "upstream_sha256": "c3de4b7056f3f952009f39f0e0d0311bc9af54807660f9e46f67c177572edf45",- "sha256": "c3de4b7056f3f952009f39f0e0d0311bc9af54807660f9e46f67c177572edf45"+ "sha256": "2d96935b59ac5c0827ad01a2d45665791003f11d8ebb8302ebce6847f25b1adf" }, { "path": "src/graphics/separation.rs",@@ -197,7 +197,7 @@ { "path": "src/graphics/shading_function.rs", "upstream_sha256": "0e0fc8bb1a32272457ef560fa80933972d0f5ef5fb7046424c6de48093cfd978",- "sha256": "0e0fc8bb1a32272457ef560fa80933972d0f5ef5fb7046424c6de48093cfd978"+ "sha256": "054181042b73bb963764d701f8e7837285f35b81b066128a93017c78e0a63bf4" }, { "path": "src/graphics/shading_pattern.rs",@@ -317,7 +317,7 @@ { "path": "src/surface.rs", "upstream_sha256": "94de0a0384fc69c9785c6595b3f2c7ed8319635c901b8dc12894e252ab3ef8da",- "sha256": "936dfeb978add9d4443658c209f2d72034583a7771179587d8135ab870ded905"+ "sha256": "a1e8cd42673ef8d3221af6100f1514280c4d6559e32559a30ea3bbd822c216f2" }, { "path": "src/text/cid.rs",@@ -1114,6 +1114,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);@@ -60,6 +60,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);@@ -49,6 +49,57 @@ 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 colours at the corners `points[0]`, `[3]`, `[6]` and `[9]`. Every patch of a mesh+ /// gradient needs its colours in the same colour space.+ pub colors: [Color; 4],+ /// The opacities at the same corners.+ pub opacities: [NormalizedF32; 4],+}++impl Eq for MeshPatch {}++impl Hash for MeshPatch {+ fn hash<H: Hasher>(&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 colours 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<MeshPatch>,+ /// 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<H: Hasher>(&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 {@@ -169,6 +220,7 @@ pub(crate) enum InnerPaint { LinearGradient(LinearGradient), RadialGradient(RadialGradient), SweepGradient(SweepGradient),+ MeshGradient(Arc<MeshGradient>), Pattern(Arc<Pattern>), } @@ -210,6 +262,12 @@ impl From<cmyk::Color> for Paint { } } +impl From<MeshGradient> for Paint {+ fn from(value: MeshGradient) -> Self {+ Paint(InnerPaint::MeshGradient(Arc::new(value)))+ }+}+ impl From<Color> for Paint { fn from(value: Color) -> Self { Paint(InnerPaint::Color(value))@@ -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; @@ -12,7 +13,7 @@ use crate::configure::ValidationError; use crate::geom::{Rect, Transform}; use crate::graphics::color::luma; use crate::graphics::color::{Color, ColorSpace};-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;@@ -82,6 +83,7 @@ impl Hash for PostScriptGradient { pub(crate) enum GradientProperties { RadialAxialGradient(RadialAxialGradient), PostScriptGradient(PostScriptGradient),+ MeshGradient(Arc<MeshGradient>), } impl GradientProperties {@@ -98,6 +100,7 @@ impl GradientProperties { return Some((&psg.stops[0].color, psg.stops[0].opacity)); } }+ GradientProperties::MeshGradient(_) => {} } None@@ -265,6 +268,23 @@ 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());+ 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.@@ -284,6 +304,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 colours (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();+ // A colour from another space can't be written in this one: 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,@@ -594,6 +594,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, };