Merge pull request #4653 from mockydevs/fix/pdf-export-mesh-shading

PDF export: gradient meshes as smooth Type 6 patch shadings
This commit is contained in:
BFlat
2026-10-30 08:42:44 +07:00
committed by GitHub
5 changed files with 177 additions and 4 deletions
+15 -2
View File
@@ -1224,7 +1224,9 @@ fn cmyk_mesh(create: Value) -> (Session, NodeId) {
(s, a)
}
/// The fill colours `document.exportPdf {standard: pdfX4}` writes (`… scn` operands), and its warnings.
/// The fill colours `document.exportPdf {standard: pdfX4}` writes (`… scn` operands, and the
/// corner colours of its gradient meshes, which are patch shadings, as PDF import reads them
/// back), and its warnings.
fn pdf_fill_colours(s: &mut Session) -> (Vec<Vec<f32>>, Vec<String>) {
let v = s.execute("document.exportPdf", &json!({"standard": "pdfX4", "compression": {"compressText": false}})).unwrap();
let bytes = vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap();
@@ -1235,10 +1237,21 @@ fn pdf_fill_colours(s: &mut Session) -> (Vec<Vec<f32>>, Vec<String>) {
for (i, w) in words.iter().enumerate() {
if *w == "scn" {
let nums: Vec<f32> = words[..i].iter().rev().map_while(|x| x.parse().ok()).collect();
out.push(nums.into_iter().rev().collect());
// A pattern (`/P0 scn`) has no colour of its own.
if !nums.is_empty() {
out.push(nums.into_iter().rev().collect());
}
}
}
}
vectorcraft_pdf::import(&bytes).unwrap().walk(|n| {
if let NodeKind::Mesh(m) = &n.kind {
out.extend(m.points.iter().map(|p| match p.color {
Color::Cmyk { c, m, y, k } => vec![c, m, y, k],
c => c.to_rgb().to_vec(),
}));
}
});
(out, serde_json::from_value(v["warnings"].clone()).unwrap())
}
+53 -1
View File
@@ -1,4 +1,4 @@
use super::exporter::{address, blend, constant_mask, cover, in_range, norm, rects_overlap, rule, to_path, writable};
use super::exporter::{address, blend, color, constant_mask, cover, in_range, norm, rects_overlap, rule, to_path, writable, xf};
use super::{
Affine, AppearanceItem, BezPath, BlendMode, Exporter, Fill, FillRule, LineCap, LineJoin, Mark, Node, NodeKind, NormalizedF32, Paint, Path, Rect,
Shape, Stroke, StrokeAlign, StrokeDash, StrokeLayer, Surface, WrittenShape, krilla, stroke,
@@ -198,6 +198,8 @@ impl Exporter<'_> {
self.node(s, &art, page, true);
}
}
// A gradient mesh is a smooth patch shading where it can be written as one.
NodeKind::Mesh(m) if self.mesh(s, m) => {}
// Live blends/envelopes/meshes export their evaluated (expanded) form.
NodeKind::Blend { .. }
| NodeKind::Envelope { .. }
@@ -217,6 +219,56 @@ impl Exporter<'_> {
}
}
/// Gradient mesh `m` as one PDF Type 6 (Coons patch) shading filling its outline (ISO 32000-1,
/// 8.7.4.5.7): each patch blends its corner colours bilinearly, as the canvas draws it, and
/// the viewer or RIP does so smoothly at its own resolution. Cutting the mesh into flat quads
/// instead banded, left seams between them, and made files with many meshes very large.
/// Corner colours are written as [`Self::page_color`] writes any colour; RGB among other
/// models goes through RGB, as the writer needs one colour space a shading (ink-based ones:
/// CMYK and grey, merge). `false` when it isn't written so: the caller draws it expanded.
/// That is a mesh with see-through points (their opacities would be a soft mask of another
/// patch shading, which a common viewer, Poppler, draws with gaps and too opaque; the quads
/// carry their own opacity), an invalid mesh, or one beyond the coordinates a file holds.
fn mesh(&mut self, s: &mut Surface, m: &vectorcraft_doc::live::GradientMesh) -> bool {
if !m.is_valid() || !m.points.iter().all(|p| p.opacity >= 1.0) {
return false;
}
let Some(bounds) = m.bounds() else { return false };
if !writable(s, Affine::IDENTITY, bounds) {
return false;
}
let Some(path) = to_path(s, &m.outline().to_bezpath()) else { return false };
let page: Vec<vectorcraft_color::Color> = m.points.iter().map(|p| self.page_color(&p.color)).collect();
let mixed = page.windows(2).any(|w| w[0].model() != w[1].model());
let rgb = page.iter().any(|c| matches!(c, vectorcraft_color::Color::Rgb { .. } | vectorcraft_color::Color::Lab { .. }));
let colors: Vec<krilla::color::Color> =
page.iter().map(|c| if mixed && rgb { color(&c.in_model(vectorcraft_color::cms::Model::Rgb)) } else { color(c) }).collect();
let pt = |p: vectorcraft_geom::Point| (p.x as f32, p.y as f32);
let mut patches = Vec::with_capacity(m.rows as usize * m.cols as usize);
for r in 0..m.rows as usize {
for c in 0..m.cols as usize {
// The corners in the order a Type 6 shading lists them: down the left edge (v),
// along the bottom (u), back up the right edge and back along the top.
let corners = [m.idx(r, c), m.idx(r + 1, c), m.idx(r + 1, c + 1), m.idx(r, c + 1)];
let (left, bottom, right, top) = (m.edge_v(r, c), m.edge_u(r + 1, c), m.edge_v(r, c + 1), m.edge_u(r, c));
let points =
[left.p0, left.p1, left.p2, left.p3, bottom.p1, bottom.p2, bottom.p3, right.p2, right.p1, right.p0, top.p2, top.p1].map(pt);
let Some(colors) =
corners.iter().map(|&i| colors.get(i).cloned()).collect::<Option<Vec<_>>>().and_then(|v| <[_; 4]>::try_from(v).ok())
else {
return false;
};
patches.push(krilla::paint::MeshPatch { points, colors, opacities: [NormalizedF32::ONE; 4] });
}
}
let mesh = krilla::paint::MeshGradient { patches, transform: xf(Affine::IDENTITY), anti_alias: false };
s.set_stroke(None);
s.set_fill(Some(Fill { paint: mesh.into(), opacity: NormalizedF32::ONE, rule: krilla::paint::FillRule::NonZero }));
s.draw_path(&path);
s.set_fill(None);
true
}
fn shape(&mut self, s: &mut Surface, n: &Node, bp: &BezPath, r: FillRule, page: Rect) {
let Some(path) = to_path(s, bp) else { return };
let bounds = bp.bounding_box();
+1
View File
@@ -29,6 +29,7 @@ mod import_overprint;
mod layers;
pub(super) mod live_text;
mod marks;
mod mesh_export;
mod output;
mod pdf13;
mod pdfx;
+106
View File
@@ -0,0 +1,106 @@
//! Gradient meshes written as PDF Type 6 (Coons patch) shadings (ISO 32000-1, 8.7.4.5.7): the
//! viewer blends each patch's corner colours smoothly, instead of the mesh being cut into small
//! flat-coloured paths that band, show seams and make the file large.
use vectorcraft_color::Color;
use vectorcraft_doc::live::{GradientMesh, MeshAppearance};
use vectorcraft_doc::{ColorMode, Document, Node, NodeKind};
use vectorcraft_geom::{Rect, shapes};
use vectorcraft_testkit::raster::{assert_similar, render_artboard};
use crate::*;
/// A 200 × 200 pt document with a 2 × 2 mesh over (20, 20)–(180, 180), its nine points coloured
/// by `color(row, col)` and made `opacity` opaque.
fn doc(color: impl Fn(usize, usize) -> Color, opacity: f32) -> Document {
let mut m = GradientMesh::for_path(&shapes::rectangle(Rect::new(20.0, 20.0, 180.0, 180.0)), 2, 2, Color::BLACK, MeshAppearance::Flat, 0.0)
.expect("a mesh");
for r in 0..=2 {
for c in 0..=2 {
let i = m.idx(r, c);
m.points[i].color = color(r, c);
m.points[i].opacity = opacity;
}
}
let mut d = Document::new(200.0, 200.0);
let l = d.layers[0].id;
let id = d.alloc_id();
d.insert(Some(l), 0, Node::new(id, NodeKind::Mesh(m))).unwrap();
d
}
fn rgb(r: usize, c: usize) -> Color {
Color::rgb(r as f32 / 2.0, c as f32 / 2.0, 1.0 - (r + c) as f32 / 4.0)
}
/// How many meshes and paths `d` holds.
fn kinds(d: &Document) -> (usize, usize) {
let (mut meshes, mut paths) = (0, 0);
d.walk(|n| match n.kind {
NodeKind::Mesh(_) => meshes += 1,
NodeKind::Path { .. } => paths += 1,
_ => {}
});
(meshes, paths)
}
#[test]
fn a_gradient_mesh_is_written_as_a_smooth_patch_shading() {
let d = doc(rgb, 1.0);
let bytes = export(&d, &PdfOptions::default()).unwrap();
let back = import(&bytes).unwrap();
// The shading comes back as a mesh, not as the 256 flat quads 8 × 8 a patch made.
let (meshes, paths) = kinds(&back);
assert!(meshes >= 1 && paths <= 4, "meshes {meshes}, paths {paths}");
let mut corners = vec![];
back.walk(|n| {
if let NodeKind::Mesh(m) = &n.kind {
corners.extend(m.points.iter().map(|p| p.color.to_rgb()));
}
});
for want in [rgb(0, 0), rgb(2, 2), rgb(1, 1)] {
let want = want.to_rgb();
assert!(corners.iter().any(|c| c.iter().zip(want).all(|(a, b)| (a - b).abs() < 0.02)), "{want:?} in {corners:?}");
}
assert_similar(&render_artboard(&d), &render_artboard(&back), 6.0, 0.01);
}
#[test]
fn a_cmyk_mesh_is_written_smooth_in_its_inks() {
let cmyk = |r: usize, c: usize| Color::Cmyk { c: r as f32 / 2.0, m: c as f32 / 2.0, y: 0.2, k: 0.0 };
let mut d = doc(cmyk, 1.0);
d.color_mode = ColorMode::Cmyk;
let back = import(&export(&d, &PdfOptions::default()).unwrap()).unwrap();
let (meshes, paths) = kinds(&back);
assert!(meshes >= 1 && paths <= 4, "meshes {meshes}, paths {paths}");
back.walk(|n| {
if let NodeKind::Mesh(m) = &n.kind {
assert!(m.points.iter().all(|p| matches!(p.color, Color::Cmyk { .. })), "{:?}", m.points);
}
});
assert_similar(&render_artboard(&d), &render_artboard(&back), 6.0, 0.02);
}
#[test]
fn a_see_through_mesh_is_still_written_as_quads() {
// Its opacities would need a soft mask of a second patch shading; the quads carry their own.
let d = doc(rgb, 0.5);
let back = import(&export(&d, &PdfOptions::default()).unwrap()).unwrap();
assert_eq!(kinds(&back).0, 0);
// The seams between quads, as before.
assert_similar(&render_artboard(&d), &render_artboard(&back), 6.0, 0.05);
}
#[test]
fn a_mesh_of_mixed_colour_models_still_exports() {
// RGB, grey and CMYK points: the PDF writer needs one colour space a shading.
let mixed = |r: usize, c: usize| match (r + c) % 3 {
0 => Color::rgb(1.0, 0.2, 0.2),
1 => Color::gray(0.4),
_ => Color::Cmyk { c: 0.8, m: 0.0, y: 0.3, k: 0.0 },
};
let d = doc(mixed, 1.0);
let back = import(&export(&d, &PdfOptions::default()).unwrap()).unwrap();
assert!(kinds(&back).0 >= 1);
assert_similar(&render_artboard(&d), &render_artboard(&back), 8.0, 0.02);
}
+2 -1
View File
@@ -1,6 +1,6 @@
# File format parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (`.ai` import: a file whose PDF part is an empty page opens from its editing data) · **Target:** Adobe Illustrator 2026 (30.x)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (PDF export: opaque gradient meshes as smooth Type 6 patch shadings) · **Target:** Adobe Illustrator 2026 (30.x)
Every format Illustrator opens, places, saves or exports, against VectorCraft's format table (`FORMATS`,
`OPEN_EXTS`, `PLACE_EXTS` in `crates/engine/src/cmd/fileio/formats.rs`). Illustrator's list comes from its public
@@ -132,6 +132,7 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri
| Date | Change | Summary |
|---|---|---|
| 2026-10-30 | minor | PDF export: an opaque gradient mesh is written as one PDF Type 6 (Coons patch) shading (ISO 32000-1, 8.7.4.5.7) filling its outline, so viewers blend its colours smoothly instead of showing the bands and seams of the 8 × 8 flat quads a patch it was cut into; a file of 65,558 small meshes exports in 14 s instead of 56 s, at half the size. A mesh with see-through points is still written as quads. Own-fixture tests in `tests::mesh_export`; scores unchanged |
| 2026-10-30 | minor | `.ai` import: a file whose PDF part is an empty page opens from its editing data where that reads, instead of opening blank; scores unchanged |
| 2026-10-30 | minor | `.ai` import: a file whose PDF part's pages are cropped to its art rather than its artboards no longer comes in as that PDF part (each page a copy of all the art) but from its layers, with its artboards, where they draw as the page does; own-fixture test in `tests_aiimport`; scores unchanged |
| 2026-10-29 | minor | `.ai` and Illustrator EPS import: the editing data is read up to an eighth of installed memory, from 512 MiB to 2 GiB on 64-bit builds (it was 512 MiB on every machine), so a layered file whose embedded rasters unpack past 512 MiB keeps its layers, groups and text on a machine with 8 GiB or more instead of opening as its page or PDF part (#3510, #4628). Measured: 1.1 GB of editing data peaked 1.1 GB above the start; data past the limit is still refused (a 2 MB file unpacking to 2.2 GB stops in under a second). 32-bit and web builds keep 256 MiB. Own-fixture tests `import::ai::tests::editing_data_past_512_mib_is_read_where_memory_allows`, `the_editing_data_limit_follows_installed_memory`; scores and hours unchanged |